mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
feat(ui): refactor chat screen architecture with new MainScaffold component
- Extract MainScaffold as new top-level container component for improved UI organization - Add TimeFormat.kt module with TimeFormatPreferenceManager for 12/24-hour and seconds display preferences - Initialize TimeFormatPreferenceManager in BitchatApplication startup sequence - Simplify notification handling by consolidating showMeshPeerList() and showPrivateChatSheet() into openPrivateChat() - Optimize QR code generation performance by replacing per-pixel setPixel calls with batch setPixels operation - Update QrCodeGenerator.generateQrBitmap() from private to public API for broader accessibility - Refactor state management methods: rename getPrivateChatSheetPeerValue() to getOpenPrivateChatPeerValue() and setPrivateChatSheetPeer() to setOpenPrivateChatPeer() - Remove unused androidx.core.graphics.set import from QrCodeGenerator - Improve documentation and code clarity across UI components and services - Update test coverage in ChatUIUtilsTest to reflect API changes
This commit is contained in:
parent
945c9e025b
commit
5af9c248d0
@ -51,6 +51,9 @@ class BitchatApplication : Application() {
|
||||
// Initialize chat UI mode (matrix transcript vs bubbles)
|
||||
com.bitchat.android.ui.theme.ChatUiModeManager.init(this)
|
||||
|
||||
// Initialize transcript clock settings (12/24-hour, seconds)
|
||||
com.bitchat.android.ui.theme.TimeFormatPreferenceManager.init(this)
|
||||
|
||||
// Initialize debug preference manager (persists debug toggles)
|
||||
try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { }
|
||||
|
||||
|
||||
@ -39,7 +39,7 @@ import com.bitchat.android.onboarding.OnboardingCoordinator
|
||||
import com.bitchat.android.onboarding.OnboardingState
|
||||
import com.bitchat.android.onboarding.PermissionExplanationScreen
|
||||
import com.bitchat.android.onboarding.PermissionManager
|
||||
import com.bitchat.android.ui.ChatScreen
|
||||
import com.bitchat.android.ui.MainScaffold
|
||||
import com.bitchat.android.ui.ChatViewModel
|
||||
import com.bitchat.android.ui.OrientationAwareActivity
|
||||
import com.bitchat.android.ui.theme.BitchatTheme
|
||||
@ -330,7 +330,7 @@ class MainActivity : OrientationAwareActivity() {
|
||||
|
||||
// Add the callback - this will be automatically removed when the activity is destroyed
|
||||
onBackPressedDispatcher.addCallback(this, backCallback)
|
||||
ChatScreen(viewModel = chatViewModel)
|
||||
MainScaffold(viewModel = chatViewModel)
|
||||
}
|
||||
|
||||
OnboardingState.ERROR -> {
|
||||
@ -810,9 +810,8 @@ class MainActivity : OrientationAwareActivity() {
|
||||
if (peerID != null) {
|
||||
Log.d("MainActivity", "Opening private chat with $senderNickname (peerID: $peerID) from notification")
|
||||
|
||||
// Open the private chat sheet with this peer
|
||||
chatViewModel.showMeshPeerList()
|
||||
chatViewModel.showPrivateChatSheet(peerID)
|
||||
// Open the conversation; the shell switches to the Chats tab behind it
|
||||
chatViewModel.openPrivateChat(peerID)
|
||||
|
||||
// Clear notifications for this sender since user is now viewing the chat
|
||||
chatViewModel.clearNotificationsForSender(peerID)
|
||||
|
||||
@ -3,7 +3,6 @@ package com.bitchat.android.hotspot
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Log
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.set
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.common.BitMatrix
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
@ -69,7 +68,7 @@ object QrCodeGenerator {
|
||||
* @param sizePx Size of the QR code in pixels
|
||||
* @return Bitmap of the QR code, or null on error
|
||||
*/
|
||||
private fun generateQrBitmap(data: String, sizePx: Int): Bitmap? {
|
||||
fun generateQrBitmap(data: String, sizePx: Int): Bitmap? {
|
||||
if (data.isBlank() || sizePx <= 0) {
|
||||
Log.w(TAG, "Invalid data or size: data.length=${data.length}, sizePx=$sizePx")
|
||||
return null
|
||||
@ -91,24 +90,28 @@ object QrCodeGenerator {
|
||||
|
||||
/**
|
||||
* Convert BitMatrix to Bitmap.
|
||||
* Pattern from VerificationSheet.kt.
|
||||
*
|
||||
* Fills an IntArray and hands it over in a single setPixels call. Bitmap.setPixel crosses into
|
||||
* native code once per pixel, so the obvious nested loop spends seconds on a full-screen QR
|
||||
* before the sheet can draw its first frame.
|
||||
*/
|
||||
private fun bitmapFromMatrix(matrix: BitMatrix): Bitmap {
|
||||
val width = matrix.width
|
||||
val height = matrix.height
|
||||
val bitmap = createBitmap(width, height)
|
||||
val black = android.graphics.Color.BLACK
|
||||
val white = android.graphics.Color.WHITE
|
||||
val pixels = IntArray(width * height)
|
||||
|
||||
for (x in 0 until width) {
|
||||
for (y in 0 until height) {
|
||||
bitmap[x, y] = if (matrix[x, y]) {
|
||||
android.graphics.Color.BLACK
|
||||
} else {
|
||||
android.graphics.Color.WHITE
|
||||
}
|
||||
for (y in 0 until height) {
|
||||
val row = y * width
|
||||
for (x in 0 until width) {
|
||||
pixels[row + x] = if (matrix[x, y]) black else white
|
||||
}
|
||||
}
|
||||
|
||||
return bitmap
|
||||
return createBitmap(width, height).apply {
|
||||
setPixels(pixels, 0, width, 0, 0, width, height)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -81,9 +81,9 @@ object ConversationAliasResolver {
|
||||
}
|
||||
|
||||
// Switch sheet peer if currently viewing an alias that got merged
|
||||
val sheetPeer = state.getPrivateChatSheetPeerValue()
|
||||
val sheetPeer = state.getOpenPrivateChatPeerValue()
|
||||
if (sheetPeer != null && mergeKeys.contains(sheetPeer)) {
|
||||
state.setPrivateChatSheetPeer(targetConversationID)
|
||||
state.setOpenPrivateChatPeer(targetConversationID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -59,7 +59,7 @@ import com.bitchat.android.ui.theme.BitchatMotion
|
||||
* - ChatUIUtils: Utility functions for formatting and colors
|
||||
*/
|
||||
@Composable
|
||||
fun ChatScreen(viewModel: ChatViewModel) {
|
||||
fun ChatScreen(viewModel: ChatViewModel, onOpenPeopleTab: () -> Unit = {}) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||
@ -77,17 +77,9 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
val commandSuggestions by viewModel.commandSuggestions.collectAsStateWithLifecycle()
|
||||
val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle()
|
||||
val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle()
|
||||
val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle()
|
||||
val showMeshPeerListSheet by viewModel.showMeshPeerList.collectAsStateWithLifecycle()
|
||||
val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle()
|
||||
val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle()
|
||||
val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle()
|
||||
val legacyPrivateMediaConsent by viewModel.legacyPrivateMediaConsent.collectAsStateWithLifecycle()
|
||||
|
||||
var messageText by remember { mutableStateOf(TextFieldValue("")) }
|
||||
var showPasswordPrompt by remember { mutableStateOf(false) }
|
||||
var showPasswordDialog by remember { mutableStateOf(false) }
|
||||
var passwordInput by remember { mutableStateOf("") }
|
||||
var showLocationChannelsSheet by remember { mutableStateOf(false) }
|
||||
var showLocationNotesSheet by remember { mutableStateOf(false) }
|
||||
var showUserSheet by remember { mutableStateOf(false) }
|
||||
@ -107,13 +99,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
)
|
||||
}
|
||||
|
||||
// Show password dialog when needed
|
||||
LaunchedEffect(showPasswordPrompt) {
|
||||
showPasswordDialog = showPasswordPrompt
|
||||
}
|
||||
|
||||
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
|
||||
val passwordPromptChannel by viewModel.passwordPromptChannel.collectAsStateWithLifecycle()
|
||||
|
||||
// Get location channel info for timeline switching
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
@ -136,8 +122,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
val isMeshTimeline =
|
||||
currentChannel == null &&
|
||||
selectedLocationChannel is ChannelID.Mesh &&
|
||||
selectedPrivatePeer == null &&
|
||||
privateChatSheetPeer == null
|
||||
selectedPrivatePeer == null
|
||||
|
||||
val processLifecycleOwner = remember { ProcessLifecycleOwner.get() }
|
||||
DisposableEffect(processLifecycleOwner, nearbyNotesController) {
|
||||
@ -166,9 +151,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isMeshTimeline, privateChatSheetPeer, selectedPrivatePeer) {
|
||||
LaunchedEffect(isMeshTimeline, selectedPrivatePeer) {
|
||||
when {
|
||||
privateChatSheetPeer != null -> liveVoiceManager.showDirectMessage(privateChatSheetPeer!!)
|
||||
selectedPrivatePeer != null -> liveVoiceManager.showDirectMessage(selectedPrivatePeer!!)
|
||||
isMeshTimeline -> liveVoiceManager.showPublicMesh()
|
||||
else -> liveVoiceManager.clearVisibleConversation()
|
||||
@ -455,7 +439,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
nickname = nickname,
|
||||
viewModel = viewModel,
|
||||
colorScheme = colorScheme,
|
||||
onSidebarToggle = { viewModel.showMeshPeerList() },
|
||||
onSidebarToggle = onOpenPeopleTab,
|
||||
onShowAppInfo = { viewModel.showAppInfo() },
|
||||
onPanicClear = { viewModel.panicClearAllData() },
|
||||
onLocationChannelsClick = { showLocationChannelsSheet = true },
|
||||
@ -515,25 +499,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
|
||||
// Dialogs and Sheets
|
||||
ChatDialogs(
|
||||
showPasswordDialog = showPasswordDialog,
|
||||
passwordPromptChannel = passwordPromptChannel,
|
||||
passwordInput = passwordInput,
|
||||
onPasswordChange = { passwordInput = it },
|
||||
onPasswordConfirm = {
|
||||
if (passwordInput.isNotEmpty()) {
|
||||
val success = viewModel.joinChannel(passwordPromptChannel!!, passwordInput)
|
||||
if (success) {
|
||||
showPasswordDialog = false
|
||||
passwordInput = ""
|
||||
}
|
||||
}
|
||||
},
|
||||
onPasswordDismiss = {
|
||||
showPasswordDialog = false
|
||||
passwordInput = ""
|
||||
},
|
||||
showAppInfo = showAppInfo,
|
||||
onAppInfoDismiss = { viewModel.hideAppInfo() },
|
||||
showLocationChannelsSheet = showLocationChannelsSheet,
|
||||
onLocationChannelsSheetDismiss = { showLocationChannelsSheet = false },
|
||||
onLocationNotesFromChannelsClick = {
|
||||
@ -550,12 +515,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
selectedUserForSheet = selectedUserForSheet,
|
||||
selectedMessageForSheet = selectedMessageForSheet,
|
||||
viewModel = viewModel,
|
||||
showVerificationSheet = showVerificationSheet,
|
||||
onVerificationSheetDismiss = viewModel::hideVerificationSheet,
|
||||
showSecurityVerificationSheet = showSecurityVerificationSheet,
|
||||
onSecurityVerificationSheetDismiss = viewModel::hideSecurityVerificationSheet,
|
||||
showMeshPeerListSheet = showMeshPeerListSheet,
|
||||
onMeshPeerListDismiss = viewModel::hideMeshPeerList,
|
||||
)
|
||||
|
||||
legacyPrivateMediaConsent?.let { request ->
|
||||
@ -814,14 +773,6 @@ private fun ChatFloatingHeader(
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ChatDialogs(
|
||||
showPasswordDialog: Boolean,
|
||||
passwordPromptChannel: String?,
|
||||
passwordInput: String,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
onPasswordConfirm: () -> Unit,
|
||||
onPasswordDismiss: () -> Unit,
|
||||
showAppInfo: Boolean,
|
||||
onAppInfoDismiss: () -> Unit,
|
||||
showLocationChannelsSheet: Boolean,
|
||||
onLocationChannelsSheetDismiss: () -> Unit,
|
||||
onLocationNotesFromChannelsClick: () -> Unit,
|
||||
@ -832,40 +783,7 @@ private fun ChatDialogs(
|
||||
selectedUserForSheet: String,
|
||||
selectedMessageForSheet: BitchatMessage?,
|
||||
viewModel: ChatViewModel,
|
||||
showVerificationSheet: Boolean,
|
||||
onVerificationSheetDismiss: () -> Unit,
|
||||
showSecurityVerificationSheet: Boolean,
|
||||
onSecurityVerificationSheetDismiss: () -> Unit,
|
||||
showMeshPeerListSheet: Boolean,
|
||||
onMeshPeerListDismiss: () -> Unit,
|
||||
) {
|
||||
val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle()
|
||||
|
||||
// Password dialog
|
||||
PasswordPromptDialog(
|
||||
show = showPasswordDialog,
|
||||
channelName = passwordPromptChannel,
|
||||
passwordInput = passwordInput,
|
||||
onPasswordChange = onPasswordChange,
|
||||
onConfirm = onPasswordConfirm,
|
||||
onDismiss = onPasswordDismiss
|
||||
)
|
||||
|
||||
// About sheet
|
||||
var showDebugSheet by remember { mutableStateOf(false) }
|
||||
AboutSheet(
|
||||
isPresented = showAppInfo,
|
||||
onDismiss = onAppInfoDismiss,
|
||||
onShowDebug = { showDebugSheet = true }
|
||||
)
|
||||
if (showDebugSheet) {
|
||||
com.bitchat.android.ui.debug.DebugSettingsSheet(
|
||||
isPresented = showDebugSheet,
|
||||
onDismiss = { showDebugSheet = false },
|
||||
meshService = viewModel.meshService
|
||||
)
|
||||
}
|
||||
|
||||
// Location channels sheet
|
||||
if (showLocationChannelsSheet) {
|
||||
LocationChannelsSheet(
|
||||
@ -893,45 +811,4 @@ private fun ChatDialogs(
|
||||
selectedMessage = selectedMessageForSheet,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
// MeshPeerList sheet (network view)
|
||||
if (showMeshPeerListSheet){
|
||||
MeshPeerListSheet(
|
||||
isPresented = showMeshPeerListSheet,
|
||||
viewModel = viewModel,
|
||||
onDismiss = onMeshPeerListDismiss,
|
||||
onShowVerification = {
|
||||
onMeshPeerListDismiss()
|
||||
viewModel.showVerificationSheet(fromSidebar = true)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showVerificationSheet) {
|
||||
VerificationSheet(
|
||||
isPresented = showVerificationSheet,
|
||||
onDismiss = onVerificationSheetDismiss,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
|
||||
if (showSecurityVerificationSheet) {
|
||||
SecurityVerificationSheet(
|
||||
isPresented = showSecurityVerificationSheet,
|
||||
onDismiss = onSecurityVerificationSheetDismiss,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
|
||||
if (privateChatSheetPeer != null) {
|
||||
PrivateChatSheet(
|
||||
isPresented = true,
|
||||
peerID = privateChatSheetPeer!!,
|
||||
viewModel = viewModel,
|
||||
onDismiss = {
|
||||
viewModel.hidePrivateChatSheet()
|
||||
viewModel.endPrivateChat()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
@ -123,11 +123,8 @@ class ChatState(
|
||||
private val _showAppInfo = MutableStateFlow<Boolean>(false)
|
||||
val showAppInfo: StateFlow<Boolean> = _showAppInfo.asStateFlow()
|
||||
|
||||
private val _showMeshPeerList = MutableStateFlow(false)
|
||||
val showMeshPeerList: StateFlow<Boolean> = _showMeshPeerList.asStateFlow()
|
||||
|
||||
private val _privateChatSheetPeer = MutableStateFlow<String?>(null)
|
||||
val privateChatSheetPeer: StateFlow<String?> = _privateChatSheetPeer.asStateFlow()
|
||||
private val _openPrivateChatPeer = MutableStateFlow<String?>(null)
|
||||
val openPrivateChatPeer: StateFlow<String?> = _openPrivateChatPeer.asStateFlow()
|
||||
|
||||
private val _showVerificationSheet = MutableStateFlow(false)
|
||||
val showVerificationSheet: StateFlow<Boolean> = _showVerificationSheet.asStateFlow()
|
||||
@ -195,8 +192,7 @@ class ChatState(
|
||||
fun getShowAppInfoValue() = _showAppInfo.value
|
||||
fun getGeohashPeopleValue() = _geohashPeople.value
|
||||
|
||||
fun getShowMeshPeerListValue() = _showMeshPeerList.value
|
||||
fun getPrivateChatSheetPeerValue() = _privateChatSheetPeer.value
|
||||
fun getOpenPrivateChatPeerValue() = _openPrivateChatPeer.value
|
||||
|
||||
fun getTeleportedGeoValue() = _teleportedGeo.value
|
||||
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value
|
||||
@ -349,11 +345,7 @@ class ChatState(
|
||||
_geohashParticipantCounts.value = counts
|
||||
}
|
||||
|
||||
fun setShowMeshPeerList(show: Boolean) {
|
||||
_showMeshPeerList.value = show
|
||||
}
|
||||
|
||||
fun setPrivateChatSheetPeer(peerID: String?) {
|
||||
_privateChatSheetPeer.value = peerID
|
||||
fun setOpenPrivateChatPeer(peerID: String?) {
|
||||
_openPrivateChatPeer.value = peerID
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.text.format.DateFormat
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
@ -10,6 +12,7 @@ import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import com.bitchat.android.ui.theme.BitchatPalette
|
||||
import com.bitchat.android.ui.theme.ChatVisualTokens
|
||||
import com.bitchat.android.ui.theme.TimeFormatPreference
|
||||
import com.bitchat.android.ui.theme.colorForPeer
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
@ -22,9 +25,43 @@ import java.util.*
|
||||
/** Opacity applied to the `#abcd` disambiguation suffix so the readable name dominates. */
|
||||
internal const val SUFFIX_ALPHA = ChatVisualTokens.SenderSuffixAlpha
|
||||
|
||||
/** Compact transcript timestamp; seconds add noise without helping conversation scanning. */
|
||||
/**
|
||||
* Compact transcript timestamp; seconds add noise without helping conversation scanning.
|
||||
*
|
||||
* Only a fixed fallback for the `timeFormatter` default arguments below, which the tests rely on.
|
||||
* Rendering code must take its formatter from [chatTimeFormatter] instead - this pattern is always
|
||||
* 24-hour and ignores the device setting.
|
||||
*/
|
||||
internal const val CHAT_TIMESTAMP_PATTERN = "HH:mm"
|
||||
|
||||
/**
|
||||
* Transcript time formatter honouring the clock settings.
|
||||
*
|
||||
* The fixed "HH:mm" pattern always rendered 24-hour, so a phone set to 12-hour showed a message
|
||||
* sent at 1:42 pm as `13:42` while its own status bar said `1:45`. getBestDateTimePattern also
|
||||
* settles the ordering and separator per locale, which a hand-written "h:mm a" would not.
|
||||
*/
|
||||
internal fun chatTimeFormatter(
|
||||
context: Context,
|
||||
preference: TimeFormatPreference = TimeFormatPreference.System,
|
||||
showSeconds: Boolean = false
|
||||
): SimpleDateFormat {
|
||||
val is24Hour = when (preference) {
|
||||
TimeFormatPreference.System -> DateFormat.is24HourFormat(context)
|
||||
TimeFormatPreference.TwelveHour -> false
|
||||
TimeFormatPreference.TwentyFourHour -> true
|
||||
}
|
||||
val skeleton = buildString {
|
||||
append(if (is24Hour) 'H' else 'h')
|
||||
append('m')
|
||||
if (showSeconds) append('s')
|
||||
}
|
||||
return SimpleDateFormat(
|
||||
DateFormat.getBestDateTimePattern(Locale.getDefault(), skeleton),
|
||||
Locale.getDefault()
|
||||
)
|
||||
}
|
||||
|
||||
/** Background opacity for a mention chip referring to somebody else. */
|
||||
internal const val MENTION_CHIP_ALPHA = ChatVisualTokens.HighlightAlpha
|
||||
|
||||
|
||||
@ -112,7 +112,7 @@ fun ChatUserSheet(
|
||||
// Mesh chat
|
||||
val peerID = selectedMessage?.senderPeerID ?: viewModel.getPeerIDForNickname(targetNickname)
|
||||
if (peerID != null) {
|
||||
viewModel.showPrivateChatSheet(peerID)
|
||||
viewModel.openPrivateChat(peerID)
|
||||
}
|
||||
}
|
||||
onDismiss()
|
||||
|
||||
@ -399,8 +399,7 @@ class ChatViewModel(
|
||||
val peerRSSI: StateFlow<Map<String, Int>> = state.peerRSSI
|
||||
val peerDirect: StateFlow<Map<String, Boolean>> = state.peerDirect
|
||||
val showAppInfo: StateFlow<Boolean> = state.showAppInfo
|
||||
val showMeshPeerList: StateFlow<Boolean> = state.showMeshPeerList
|
||||
val privateChatSheetPeer: StateFlow<String?> = state.privateChatSheetPeer
|
||||
val openPrivateChatPeer: StateFlow<String?> = state.openPrivateChatPeer
|
||||
val showVerificationSheet: StateFlow<Boolean> = state.showVerificationSheet
|
||||
val showSecurityVerificationSheet: StateFlow<Boolean> = state.showSecurityVerificationSheet
|
||||
val legacyPrivateMediaConsent: StateFlow<LegacyPrivateMediaConsentRequest?> =
|
||||
@ -742,7 +741,7 @@ class ChatViewModel(
|
||||
// Clear mesh mention notifications since user is now back in mesh chat
|
||||
clearMeshMentionNotifications()
|
||||
// Ensure sheet is hidden
|
||||
hidePrivateChatSheet()
|
||||
closePrivateChat()
|
||||
}
|
||||
|
||||
internal suspend fun deletePrivateConversation(
|
||||
@ -786,13 +785,13 @@ class ChatViewModel(
|
||||
privateChatManager.endPrivateChat()
|
||||
setCurrentPrivateChatPeer(null)
|
||||
}
|
||||
val sheetPeer = state.getPrivateChatSheetPeerValue()
|
||||
val sheetPeer = state.getOpenPrivateChatPeerValue()
|
||||
if (
|
||||
sheetPeer != null &&
|
||||
ContactDirectory.canonicalConversationId(sheetPeer)
|
||||
.equals(canonicalID, ignoreCase = true)
|
||||
) {
|
||||
hidePrivateChatSheet()
|
||||
closePrivateChat()
|
||||
}
|
||||
clearNotificationsForSender(canonicalID)
|
||||
notificationManager.removeConversationShortcut(canonicalID)
|
||||
@ -917,7 +916,7 @@ class ChatViewModel(
|
||||
canonical ?: targetKey
|
||||
}
|
||||
|
||||
showPrivateChatSheet(openPeer)
|
||||
openPrivateChat(openPeer)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "openLatestUnreadPrivateChat failed: ${e.message}")
|
||||
}
|
||||
@ -989,8 +988,8 @@ class ChatViewModel(
|
||||
if (canonical != state.getSelectedPrivateChatPeerValue()) {
|
||||
privateChatManager.startPrivateChat(canonical, mesh)
|
||||
// If we're in the private chat sheet, update its active peer too
|
||||
if (state.getPrivateChatSheetPeerValue() != null) {
|
||||
showPrivateChatSheet(canonical)
|
||||
if (state.getOpenPrivateChatPeerValue() != null) {
|
||||
openPrivateChat(canonical)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1303,21 +1302,12 @@ class ChatViewModel(
|
||||
notificationManager.clearMeshMentionNotifications()
|
||||
}
|
||||
|
||||
private var reopenSidebarAfterVerification = false
|
||||
|
||||
fun showVerificationSheet(fromSidebar: Boolean = false) {
|
||||
if (fromSidebar) {
|
||||
reopenSidebarAfterVerification = true
|
||||
}
|
||||
fun showVerificationSheet() {
|
||||
state.setShowVerificationSheet(true)
|
||||
}
|
||||
|
||||
fun hideVerificationSheet() {
|
||||
state.setShowVerificationSheet(false)
|
||||
if (reopenSidebarAfterVerification) {
|
||||
reopenSidebarAfterVerification = false
|
||||
state.setShowMeshPeerList(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun showSecurityVerificationSheet() {
|
||||
@ -1328,21 +1318,13 @@ class ChatViewModel(
|
||||
state.setShowSecurityVerificationSheet(false)
|
||||
}
|
||||
|
||||
fun showMeshPeerList() {
|
||||
state.setShowMeshPeerList(true)
|
||||
}
|
||||
|
||||
fun hideMeshPeerList() {
|
||||
state.setShowMeshPeerList(false)
|
||||
}
|
||||
|
||||
fun showPrivateChatSheet(peerID: String) {
|
||||
fun openPrivateChat(peerID: String) {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
state.setPrivateChatSheetPeer(conversationID)
|
||||
state.setOpenPrivateChatPeer(conversationID)
|
||||
}
|
||||
|
||||
fun hidePrivateChatSheet() {
|
||||
state.setPrivateChatSheetPeer(null)
|
||||
fun closePrivateChat() {
|
||||
state.setOpenPrivateChatPeer(null)
|
||||
}
|
||||
|
||||
fun getPeerFingerprintForDisplay(peerID: String): String? {
|
||||
@ -1648,19 +1630,19 @@ class ChatViewModel(
|
||||
*/
|
||||
fun startGeohashDM(pubkeyHex: String) {
|
||||
geohashViewModel.startGeohashDM(pubkeyHex) { convKey ->
|
||||
showPrivateChatSheet(convKey)
|
||||
openPrivateChat(convKey)
|
||||
}
|
||||
}
|
||||
|
||||
fun startGeohashDMByNickname(nickname: String) {
|
||||
geohashViewModel.startGeohashDMByNickname(nickname) { convKey ->
|
||||
showPrivateChatSheet(convKey)
|
||||
openPrivateChat(convKey)
|
||||
}
|
||||
}
|
||||
|
||||
fun startGeohashDMByShortId(shortId: String) {
|
||||
geohashViewModel.startGeohashDMByShortId(shortId) { convKey ->
|
||||
showPrivateChatSheet(convKey)
|
||||
openPrivateChat(convKey)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1685,6 +1667,11 @@ class ChatViewModel(
|
||||
state.setShowAppInfo(false)
|
||||
}
|
||||
|
||||
fun dismissPasswordPrompt() {
|
||||
state.setShowPasswordPrompt(false)
|
||||
state.setPasswordPromptChannel(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Android back navigation
|
||||
* Returns true if the back press was handled, false if it should be passed to the system
|
||||
@ -1703,7 +1690,7 @@ class ChatViewModel(
|
||||
true
|
||||
}
|
||||
// Exit private chat
|
||||
state.getSelectedPrivateChatPeerValue() != null || state.getPrivateChatSheetPeerValue() != null -> {
|
||||
state.getSelectedPrivateChatPeerValue() != null || state.getOpenPrivateChatPeerValue() != null -> {
|
||||
endPrivateChat()
|
||||
true
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@ import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.nostr.LocationNotesManager
|
||||
|
||||
/**
|
||||
* Location Notes button for MainHeader.
|
||||
* Location Notes button for MainHeader - the entry point for reading and writing notes here.
|
||||
* Mesh-only with location authorized. Tor health tints the glyph with muted colours
|
||||
* and a slow glow while connecting (via [rememberTorConnectionVisual]).
|
||||
*/
|
||||
@ -44,12 +44,10 @@ fun LocationNotesButton(
|
||||
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
|
||||
val locationEnabled = locationPermissionGranted && locationServicesEnabled
|
||||
|
||||
val notesManager = remember { LocationNotesManager.getInstance() }
|
||||
val notes by notesManager.notes.collectAsStateWithLifecycle()
|
||||
val notesCount = notes.size
|
||||
|
||||
// Keep the header quiet until there is at least one nearby note worth opening.
|
||||
if (selectedLocationChannel is ChannelID.Mesh && locationEnabled && notesCount > 0) {
|
||||
// Shown whenever notes are usable here, not only once notes exist. Gating on a non-empty list
|
||||
// kept the header tidy but left writing the first note with no affordance at all - the only
|
||||
// way in was a row below the fold of the channels sheet.
|
||||
if (selectedLocationChannel is ChannelID.Mesh && locationEnabled) {
|
||||
val contentDescription = stringResource(R.string.cd_location_notes)
|
||||
val torVisual = rememberTorConnectionVisual(normal = colorScheme.primary)
|
||||
|
||||
|
||||
366
app/src/main/java/com/bitchat/android/ui/MainScaffold.kt
Normal file
366
app/src/main/java/com/bitchat/android/ui/MainScaffold.kt
Normal file
@ -0,0 +1,366 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.QrCode
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationBarItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
|
||||
/**
|
||||
* The app's four destinations, in bar order.
|
||||
*
|
||||
* `Public` is the mesh/geohash timeline the app used to be entirely made of; the other three were
|
||||
* modal bottom sheets before this became a tabbed app.
|
||||
*/
|
||||
enum class AppTab(
|
||||
val labelRes: Int,
|
||||
val iconRes: Int? = null,
|
||||
val iconVector: ImageVector? = null
|
||||
) {
|
||||
Public(R.string.tab_public, iconRes = R.drawable.ic_spec_globe),
|
||||
Chats(R.string.tab_chats, iconRes = R.drawable.ic_spec_envelope),
|
||||
People(R.string.people, iconRes = R.drawable.ic_spec_people),
|
||||
Settings(R.string.about_tab_settings, iconVector = Icons.Outlined.Settings)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab shell hosting the whole app.
|
||||
*
|
||||
* A `when` over a saveable enum rather than a NavHost: four fixed destinations sharing one
|
||||
* ChatViewModel need no route DSL, no argument encoding and no graph. The one nested destination -
|
||||
* an open conversation - is a nullable peer id held by the Chats tab.
|
||||
*/
|
||||
@Composable
|
||||
fun MainScaffold(viewModel: ChatViewModel, modifier: Modifier = Modifier) {
|
||||
// Public leads the bar and is the default: a first-run user has no conversations, and the mesh
|
||||
// timeline is the thing the app exists for.
|
||||
var tab by rememberSaveable { mutableStateOf(AppTab.Public) }
|
||||
|
||||
// Which conversation is open is the ViewModel's business, not the shell's: notification taps,
|
||||
// deep links and alias re-resolution all set it from outside compose. The shell only reads it.
|
||||
val openConversation by viewModel.openPrivateChatPeer.collectAsStateWithLifecycle()
|
||||
|
||||
val openConversationFor: (String) -> Unit = { peerID ->
|
||||
viewModel.openPrivateChat(peerID)
|
||||
tab = AppTab.Chats
|
||||
}
|
||||
|
||||
// Closing a conversation is already handled by the activity's back callback via
|
||||
// ChatViewModel.handleBackPressed, so this only covers the remaining case: returning to the
|
||||
// default tab before back is allowed to leave the app.
|
||||
BackHandler(enabled = openConversation == null && tab != AppTab.Public) {
|
||||
tab = AppTab.Public
|
||||
}
|
||||
|
||||
val inConversation = openConversation != null
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
// The screens below draw their own status-bar treatment (the chat header fades into it),
|
||||
// so the Scaffold must not pad the top. The bottom bar still reports its own height.
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
bottomBar = {
|
||||
AnimatedVisibility(
|
||||
visible = !inConversation,
|
||||
enter = slideInVertically { it } + fadeIn(),
|
||||
exit = slideOutVertically { it } + fadeOut()
|
||||
) {
|
||||
BitchatNavigationBar(
|
||||
viewModel = viewModel,
|
||||
selected = tab,
|
||||
onSelect = { tab = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
// consumeWindowInsets is what keeps the bar from being counted twice: without it the
|
||||
// screens' own navigationBars/ime padding would stack on top of the space the Scaffold
|
||||
// already reserved for the bar.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.consumeWindowInsets(innerPadding)
|
||||
) {
|
||||
val peer = openConversation
|
||||
if (peer != null) {
|
||||
PrivateChatContent(
|
||||
peerID = peer,
|
||||
viewModel = viewModel,
|
||||
onBack = viewModel::endPrivateChat
|
||||
)
|
||||
} else {
|
||||
when (tab) {
|
||||
AppTab.Chats -> TabScreen(title = stringResource(R.string.conversations)) {
|
||||
ChatsListContent(
|
||||
viewModel = viewModel,
|
||||
onOpenConversation = openConversationFor,
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
AppTab.Public -> ChatScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenPeopleTab = { tab = AppTab.People }
|
||||
)
|
||||
|
||||
AppTab.People -> TabScreen(
|
||||
title = stringResource(R.string.your_network),
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = { viewModel.showVerificationSheet() },
|
||||
modifier = Modifier.size(44.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.QrCode,
|
||||
contentDescription = stringResource(R.string.verify_title),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
PeopleListContent(
|
||||
viewModel = viewModel,
|
||||
onOpenConversation = openConversationFor,
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
AppTab.Settings -> Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
) {
|
||||
AboutContent(
|
||||
initialTab = AboutTab.Settings,
|
||||
contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppDialogs(viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Surfaces that can be opened from more than one tab, hosted by the shell rather than by a tab.
|
||||
*
|
||||
* These used to live inside ChatScreen. Once ChatScreen became one tab among four, anything
|
||||
* declared there only existed while that tab was on screen - so tapping the QR button in People
|
||||
* flipped the ViewModel flag but showed nothing until you happened to navigate to Public, where
|
||||
* the sheet was waiting.
|
||||
*/
|
||||
@Composable
|
||||
private fun AppDialogs(viewModel: ChatViewModel) {
|
||||
val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle()
|
||||
val showVerification by viewModel.showVerificationSheet.collectAsStateWithLifecycle()
|
||||
val showSecurityVerification by
|
||||
viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle()
|
||||
val showPasswordPrompt by viewModel.showPasswordPrompt.collectAsStateWithLifecycle()
|
||||
val passwordPromptChannel by viewModel.passwordPromptChannel.collectAsStateWithLifecycle()
|
||||
|
||||
var passwordInput by remember { mutableStateOf("") }
|
||||
var showDebugSheet by remember { mutableStateOf(false) }
|
||||
|
||||
PasswordPromptDialog(
|
||||
show = showPasswordPrompt,
|
||||
channelName = passwordPromptChannel,
|
||||
passwordInput = passwordInput,
|
||||
onPasswordChange = { passwordInput = it },
|
||||
onConfirm = {
|
||||
val channel = passwordPromptChannel
|
||||
if (passwordInput.isNotEmpty() && channel != null) {
|
||||
if (viewModel.joinChannel(channel, passwordInput)) passwordInput = ""
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
passwordInput = ""
|
||||
viewModel.dismissPasswordPrompt()
|
||||
}
|
||||
)
|
||||
|
||||
AboutSheet(
|
||||
isPresented = showAppInfo,
|
||||
onDismiss = viewModel::hideAppInfo,
|
||||
onShowDebug = { showDebugSheet = true }
|
||||
)
|
||||
if (showDebugSheet) {
|
||||
com.bitchat.android.ui.debug.DebugSettingsSheet(
|
||||
isPresented = true,
|
||||
onDismiss = { showDebugSheet = false },
|
||||
meshService = viewModel.meshService
|
||||
)
|
||||
}
|
||||
|
||||
if (showVerification) {
|
||||
VerificationSheet(
|
||||
isPresented = true,
|
||||
onDismiss = viewModel::hideVerificationSheet,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
|
||||
if (showSecurityVerification) {
|
||||
SecurityVerificationSheet(
|
||||
isPresented = true,
|
||||
onDismiss = viewModel::hideSecurityVerificationSheet,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chrome for the list tabs: a title bar built from the same tokens as the chat header, so switching
|
||||
* tabs does not shift the bar's height, insets or type.
|
||||
*/
|
||||
@Composable
|
||||
private fun TabScreen(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
actions: @Composable () -> Unit = {},
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(colorScheme.background)
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
) {
|
||||
androidx.compose.foundation.layout.Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ChatHeaderHeight)
|
||||
.padding(start = 16.dp, end = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
actions()
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant)
|
||||
Box(modifier = Modifier.fillMaxSize()) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BitchatNavigationBar(
|
||||
viewModel: ChatViewModel,
|
||||
selected: AppTab,
|
||||
onSelect: (AppTab) -> Unit
|
||||
) {
|
||||
val hasUnreadPrivate by viewModel.hasUnreadPrivateMessages.collectAsStateWithLifecycle()
|
||||
val hasUnreadChannels by viewModel.hasUnreadChannels.collectAsStateWithLifecycle()
|
||||
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||
val peerCount = connectedPeers.count { it != viewModel.myPeerID }
|
||||
|
||||
NavigationBar(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 0.dp
|
||||
) {
|
||||
AppTab.entries.forEach { entry ->
|
||||
val label = stringResource(entry.labelRes)
|
||||
NavigationBarItem(
|
||||
selected = entry == selected,
|
||||
onClick = { onSelect(entry) },
|
||||
icon = {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
when (entry) {
|
||||
AppTab.Chats -> if (hasUnreadPrivate) Badge()
|
||||
AppTab.Public -> if (hasUnreadChannels) Badge()
|
||||
AppTab.People -> if (peerCount > 0) {
|
||||
Badge { Text(peerCount.toString()) }
|
||||
}
|
||||
AppTab.Settings -> Unit
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (entry.iconVector != null) {
|
||||
Icon(
|
||||
imageVector = entry.iconVector,
|
||||
contentDescription = label,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(entry.iconRes!!),
|
||||
contentDescription = label,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = label,
|
||||
fontFamily = BitchatFontFamily,
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
},
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||
indicatorColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -130,10 +130,10 @@ class MeshDelegateHandler(
|
||||
}
|
||||
}
|
||||
|
||||
state.getPrivateChatSheetPeerValue()?.let { sheetPeer ->
|
||||
state.getOpenPrivateChatPeerValue()?.let { sheetPeer ->
|
||||
val canonical = ContactDirectory.canonicalConversationId(sheetPeer)
|
||||
if (canonical != sheetPeer) {
|
||||
state.setPrivateChatSheetPeer(canonical)
|
||||
state.setOpenPrivateChatPeer(canonical)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -13,7 +13,6 @@ import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
@ -25,7 +24,6 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
@ -52,11 +50,6 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetCenterTopBar
|
||||
import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
|
||||
import com.bitchat.android.favorites.FavoriteRelationship
|
||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
@ -80,54 +73,42 @@ import kotlinx.coroutines.delay
|
||||
* Extracted from ChatScreen.kt for better organization
|
||||
*/
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
/**
|
||||
* Conversations tab body: private conversations, then joined channels.
|
||||
*
|
||||
* Lifted verbatim out of the old MeshPeerListSheet so the same list can be a first-class tab
|
||||
* destination instead of a modal. The rows, swipe actions and delete flow are unchanged - only the
|
||||
* container and the "open a conversation" callback differ.
|
||||
*/
|
||||
@Composable
|
||||
fun MeshPeerListSheet(
|
||||
isPresented: Boolean,
|
||||
fun ChatsListContent(
|
||||
viewModel: ChatViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onShowVerification: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
onOpenConversation: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp)
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
|
||||
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
|
||||
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
|
||||
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
||||
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
val conversations by viewModel.conversations.collectAsStateWithLifecycle()
|
||||
val conversationStoreState by viewModel.conversationStoreState.collectAsStateWithLifecycle()
|
||||
val peerDirect by viewModel.peerDirect.collectAsStateWithLifecycle()
|
||||
val geohashPeopleCount = geohashPeople.size
|
||||
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
|
||||
val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() }
|
||||
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
val directPeerIdentityIDs = remember(peerDirect) {
|
||||
peerDirect
|
||||
.asSequence()
|
||||
.filter { (_, isDirect) -> isDirect }
|
||||
.mapTo(mutableSetOf()) { (peerID, _) -> peerID.lowercase() }
|
||||
}
|
||||
val wifiAwareIdentityIDs = remember(wifiAwarePeerIDs) {
|
||||
wifiAwarePeerIDs.mapTo(mutableSetOf()) { it.lowercase() }
|
||||
}
|
||||
val conversationIdentityAliases = remember(conversations) {
|
||||
conversations
|
||||
.flatMapTo(mutableSetOf()) { it.identityAliases }
|
||||
}
|
||||
val visibleConnectedPeers = connectedPeers.filterNot { peerID ->
|
||||
val aliases = runCatching {
|
||||
ContactDirectory.aliasesForConversation(peerID)
|
||||
}.getOrDefault(setOf(peerID))
|
||||
aliases.any { it.lowercase() in conversationIdentityAliases }
|
||||
}
|
||||
var pendingConversationDelete by remember {
|
||||
mutableStateOf<ConversationSummary?>(null)
|
||||
val wifiAwareIdentityIDs = remember(wifiAwareConnected) {
|
||||
wifiAwareConnected.keys.mapTo(mutableSetOf()) { it.lowercase() }
|
||||
}
|
||||
|
||||
var pendingConversationDelete by remember { mutableStateOf<ConversationSummary?>(null) }
|
||||
var conversationQuery by rememberSaveable { mutableStateOf("") }
|
||||
val filteredConversations = remember(conversations, conversationQuery) {
|
||||
val query = conversationQuery.trim()
|
||||
@ -143,423 +124,347 @@ fun MeshPeerListSheet(
|
||||
val offlineConversations = remember(filteredConversations) {
|
||||
filteredConversations.filterNot(ConversationSummary::isConnected)
|
||||
}
|
||||
val sheetScope = rememberCoroutineScope()
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Bottom sheet state
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
|
||||
// Scroll state for animated top bar
|
||||
val listState = rememberLazyListState()
|
||||
val isScrolled by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
|
||||
}
|
||||
}
|
||||
val topBarAlpha by animateFloatAsState(
|
||||
targetValue = if (isScrolled) 0.95f else 0f,
|
||||
label = "topBarAlpha"
|
||||
)
|
||||
|
||||
if (isPresented) {
|
||||
BitchatBottomSheet(
|
||||
modifier = modifier,
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = contentPadding
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp)
|
||||
) {
|
||||
val peopleCount = when (selectedLocationChannel) {
|
||||
is ChannelID.Location -> geohashPeopleCount
|
||||
else -> visibleConnectedPeers.count { it != viewModel.myPeerID }
|
||||
}
|
||||
if (conversations.size >= CONVERSATION_SEARCH_THRESHOLD) {
|
||||
item(key = "private_conversations_search") {
|
||||
OutlinedTextField(
|
||||
value = conversationQuery,
|
||||
onValueChange = { conversationQuery = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding)
|
||||
.padding(top = 10.dp),
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = BitchatFontFamily
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
stringResource(R.string.search_conversations),
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.Outlined.Search, contentDescription = null)
|
||||
},
|
||||
trailingIcon = if (conversationQuery.isNotEmpty()) {
|
||||
{
|
||||
IconButton(onClick = { conversationQuery = "" }) {
|
||||
Icon(
|
||||
Icons.Outlined.Close,
|
||||
contentDescription = stringResource(R.string.clear)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
shape = MaterialTheme.shapes.medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "private_conversations_header") {
|
||||
SheetIconSectionHeader(
|
||||
iconRes = R.drawable.ic_spec_envelope,
|
||||
title = stringResource(R.string.conversations),
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
when {
|
||||
conversationStoreState is
|
||||
com.bitchat.android.services.ConversationStoreState.Loading &&
|
||||
conversations.isEmpty() -> {
|
||||
item(key = "private_conversations_loading") {
|
||||
ConversationSectionStatus(
|
||||
icon = { CircularProgressIndicator(Modifier.size(20.dp)) },
|
||||
text = stringResource(R.string.loading_conversations)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (conversations.size >= CONVERSATION_SEARCH_THRESHOLD) {
|
||||
item(key = "private_conversations_search") {
|
||||
OutlinedTextField(
|
||||
value = conversationQuery,
|
||||
onValueChange = { conversationQuery = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding)
|
||||
.padding(top = 10.dp),
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = BitchatFontFamily
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
stringResource(R.string.search_conversations),
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.Outlined.Search, contentDescription = null)
|
||||
},
|
||||
trailingIcon = if (conversationQuery.isNotEmpty()) {
|
||||
{
|
||||
IconButton(onClick = { conversationQuery = "" }) {
|
||||
Icon(
|
||||
Icons.Outlined.Close,
|
||||
contentDescription = stringResource(R.string.clear)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
conversationStoreState is
|
||||
com.bitchat.android.services.ConversationStoreState.Loading &&
|
||||
conversations.isEmpty() -> {
|
||||
item(key = "private_conversations_loading") {
|
||||
ConversationSectionStatus(
|
||||
icon = { CircularProgressIndicator(Modifier.size(20.dp)) },
|
||||
text = stringResource(R.string.loading_conversations)
|
||||
conversationStoreState is
|
||||
com.bitchat.android.services.ConversationStoreState.Error &&
|
||||
conversations.isEmpty() -> {
|
||||
item(key = "private_conversations_error") {
|
||||
ConversationSectionStatus(
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Outlined.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
conversationStoreState is
|
||||
com.bitchat.android.services.ConversationStoreState.Error &&
|
||||
conversations.isEmpty() -> {
|
||||
item(key = "private_conversations_error") {
|
||||
ConversationSectionStatus(
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Outlined.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
text = stringResource(R.string.conversation_storage_error)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
conversations.isEmpty() -> {
|
||||
item(key = "private_conversations_empty") {
|
||||
ConversationSectionStatus(
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_spec_envelope),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
text = stringResource(R.string.no_conversations_yet)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
filteredConversations.isEmpty() -> {
|
||||
item(key = "private_conversations_no_results") {
|
||||
ConversationSectionStatus(
|
||||
icon = {
|
||||
Icon(Icons.Outlined.SearchOff, contentDescription = null)
|
||||
},
|
||||
text = stringResource(R.string.no_conversation_results)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
text = stringResource(R.string.conversation_storage_error)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (onlineConversations.isNotEmpty()) {
|
||||
item(key = "private_conversations_online_label") {
|
||||
ConversationGroupLabel(
|
||||
text = stringResource(R.string.online_conversations)
|
||||
)
|
||||
}
|
||||
itemsIndexed(
|
||||
items = onlineConversations,
|
||||
key = { _, conversation ->
|
||||
"conversation:${conversation.conversationID}"
|
||||
}
|
||||
) { index, conversation ->
|
||||
ConversationSwipeItem(
|
||||
conversation = conversation,
|
||||
directPeerIdentityIDs = directPeerIdentityIDs,
|
||||
wifiAwareIdentityIDs = wifiAwareIdentityIDs,
|
||||
viewModel = viewModel,
|
||||
isFirst = index == 0,
|
||||
isLast = index == onlineConversations.lastIndex,
|
||||
onPrivateChatStart = { conversationID ->
|
||||
viewModel.showPrivateChatSheet(conversationID)
|
||||
onDismiss()
|
||||
},
|
||||
onDeleteRequested = { pendingConversationDelete = it },
|
||||
onReadStateRequested = { item, isRead ->
|
||||
sheetScope.launch {
|
||||
viewModel.setConversationRead(item.conversationID, isRead)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.animateItem()
|
||||
)
|
||||
}
|
||||
conversations.isEmpty() -> {
|
||||
item(key = "private_conversations_empty") {
|
||||
ConversationSectionStatus(
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_spec_envelope),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
text = stringResource(R.string.no_conversations_yet)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (offlineConversations.isNotEmpty()) {
|
||||
item(key = "private_conversations_offline_label") {
|
||||
ConversationGroupLabel(
|
||||
text = stringResource(R.string.offline_conversations)
|
||||
)
|
||||
}
|
||||
itemsIndexed(
|
||||
items = offlineConversations,
|
||||
key = { _, conversation ->
|
||||
"conversation:${conversation.conversationID}"
|
||||
}
|
||||
) { index, conversation ->
|
||||
ConversationSwipeItem(
|
||||
conversation = conversation,
|
||||
directPeerIdentityIDs = directPeerIdentityIDs,
|
||||
wifiAwareIdentityIDs = wifiAwareIdentityIDs,
|
||||
viewModel = viewModel,
|
||||
isFirst = index == 0,
|
||||
isLast = index == offlineConversations.lastIndex,
|
||||
onPrivateChatStart = { conversationID ->
|
||||
viewModel.showPrivateChatSheet(conversationID)
|
||||
onDismiss()
|
||||
},
|
||||
onDeleteRequested = { pendingConversationDelete = it },
|
||||
onReadStateRequested = { item, isRead ->
|
||||
sheetScope.launch {
|
||||
viewModel.setConversationRead(item.conversationID, isRead)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.animateItem()
|
||||
)
|
||||
}
|
||||
filteredConversations.isEmpty() -> {
|
||||
item(key = "private_conversations_no_results") {
|
||||
ConversationSectionStatus(
|
||||
icon = { Icon(Icons.Outlined.SearchOff, contentDescription = null) },
|
||||
text = stringResource(R.string.no_conversation_results)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Channels section
|
||||
if (joinedChannels.isNotEmpty()) {
|
||||
item(key = "channels_section") {
|
||||
if (onlineConversations.isNotEmpty()) {
|
||||
item(key = "private_conversations_online_label") {
|
||||
ConversationGroupLabel(text = stringResource(R.string.online_conversations))
|
||||
}
|
||||
itemsIndexed(
|
||||
items = onlineConversations,
|
||||
key = { _, conversation -> "conversation:" + conversation.conversationID }
|
||||
) { index, conversation ->
|
||||
ConversationSwipeItem(
|
||||
conversation = conversation,
|
||||
directPeerIdentityIDs = directPeerIdentityIDs,
|
||||
wifiAwareIdentityIDs = wifiAwareIdentityIDs,
|
||||
viewModel = viewModel,
|
||||
isFirst = index == 0,
|
||||
isLast = index == onlineConversations.lastIndex,
|
||||
onPrivateChatStart = onOpenConversation,
|
||||
onDeleteRequested = { pendingConversationDelete = it },
|
||||
onReadStateRequested = { item, isRead ->
|
||||
scope.launch {
|
||||
viewModel.setConversationRead(item.conversationID, isRead)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.animateItem()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (offlineConversations.isNotEmpty()) {
|
||||
item(key = "private_conversations_offline_label") {
|
||||
ConversationGroupLabel(text = stringResource(R.string.offline_conversations))
|
||||
}
|
||||
itemsIndexed(
|
||||
items = offlineConversations,
|
||||
key = { _, conversation -> "conversation:" + conversation.conversationID }
|
||||
) { index, conversation ->
|
||||
ConversationSwipeItem(
|
||||
conversation = conversation,
|
||||
directPeerIdentityIDs = directPeerIdentityIDs,
|
||||
wifiAwareIdentityIDs = wifiAwareIdentityIDs,
|
||||
viewModel = viewModel,
|
||||
isFirst = index == 0,
|
||||
isLast = index == offlineConversations.lastIndex,
|
||||
onPrivateChatStart = onOpenConversation,
|
||||
onDeleteRequested = { pendingConversationDelete = it },
|
||||
onReadStateRequested = { item, isRead ->
|
||||
scope.launch {
|
||||
viewModel.setConversationRead(item.conversationID, isRead)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.animateItem()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (joinedChannels.isNotEmpty()) {
|
||||
item(key = "channels_section") {
|
||||
Column {
|
||||
SheetIconSectionHeader(
|
||||
iconRes = R.drawable.ic_spec_chat_bubbles,
|
||||
title = stringResource(R.string.channels),
|
||||
modifier = Modifier.padding(
|
||||
top = if (conversations.isNotEmpty()) 20.dp else 8.dp
|
||||
)
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding)
|
||||
.padding(top = 10.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Column {
|
||||
SheetIconSectionHeader(
|
||||
iconRes = R.drawable.ic_spec_chat_bubbles,
|
||||
title = stringResource(R.string.channels),
|
||||
modifier = Modifier.padding(
|
||||
top = if (conversations.isNotEmpty()) 20.dp else 8.dp
|
||||
joinedChannels.toList().forEachIndexed { index, channel ->
|
||||
if (index > 0) SheetCardDivider()
|
||||
val unreadCount = unreadChannelMessages[channel] ?: 0
|
||||
ChannelRow(
|
||||
channel = channel,
|
||||
isSelected = channel == currentChannel,
|
||||
unreadCount = unreadCount,
|
||||
colorScheme = colorScheme,
|
||||
onChannelClick = {
|
||||
if (channel.startsWith("@")) {
|
||||
val peerName = channel.removePrefix("@")
|
||||
val peerID = peerNicknames.entries
|
||||
.firstOrNull { it.value == peerName }?.key
|
||||
if (peerID != null) onOpenConversation(peerID)
|
||||
} else {
|
||||
viewModel.switchToChannel(channel)
|
||||
}
|
||||
},
|
||||
onLeaveChannel = { viewModel.leaveChannel(channel) },
|
||||
)
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding)
|
||||
.padding(top = 10.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Column {
|
||||
joinedChannels.toList().forEachIndexed { index, channel ->
|
||||
if (index > 0) SheetCardDivider()
|
||||
val isSelected = channel == currentChannel
|
||||
val unreadCount = unreadChannelMessages[channel] ?: 0
|
||||
ChannelRow(
|
||||
channel = channel,
|
||||
isSelected = isSelected,
|
||||
unreadCount = unreadCount,
|
||||
colorScheme = colorScheme,
|
||||
onChannelClick = {
|
||||
if (channel.startsWith("@")) {
|
||||
val peerName = channel.removePrefix("@")
|
||||
val peerID =
|
||||
peerNicknames.entries.firstOrNull { it.value == peerName }?.key
|
||||
if (peerID != null) {
|
||||
viewModel.showPrivateChatSheet(peerID)
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
viewModel.switchToChannel(channel)
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
onLeaveChannel = {
|
||||
viewModel.leaveChannel(channel)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// People / geohash participants
|
||||
item(key = "people_section") {
|
||||
when (selectedLocationChannel) {
|
||||
is ChannelID.Location -> {
|
||||
GeohashPeopleList(
|
||||
viewModel = viewModel,
|
||||
onTapPerson = onDismiss,
|
||||
excludedIdentityAliases = conversationIdentityAliases,
|
||||
modifier = Modifier.padding(
|
||||
top = if (
|
||||
joinedChannels.isNotEmpty() ||
|
||||
conversations.isNotEmpty()
|
||||
) 20.dp else 8.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
PeopleSection(
|
||||
modifier = Modifier.padding(
|
||||
top = if (
|
||||
joinedChannels.isNotEmpty() ||
|
||||
conversations.isNotEmpty()
|
||||
) 20.dp else 8.dp
|
||||
),
|
||||
connectedPeers = visibleConnectedPeers,
|
||||
peerNicknames = peerNicknames,
|
||||
peerRSSI = peerRSSI,
|
||||
nickname = nickname,
|
||||
colorScheme = colorScheme,
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
wifiAwarePeerIDs = wifiAwarePeerIDs,
|
||||
peopleCount = peopleCount,
|
||||
excludedIdentityAliases = conversationIdentityAliases,
|
||||
viewModel = viewModel,
|
||||
onPrivateChatStart = { peerID ->
|
||||
viewModel.showPrivateChatSheet(peerID)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TopBar (animated)
|
||||
BitchatSheetTopBar(
|
||||
title = {
|
||||
BitchatSheetTitle(text = stringResource(id = R.string.your_network))
|
||||
},
|
||||
backgroundAlpha = topBarAlpha,
|
||||
actions = {
|
||||
if (selectedLocationChannel !is ChannelID.Location) {
|
||||
IconButton(
|
||||
onClick = onShowVerification,
|
||||
modifier = Modifier.size(44.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.QrCode,
|
||||
contentDescription = stringResource(R.string.verify_title),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onClose = onDismiss,
|
||||
)
|
||||
|
||||
SnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pendingConversationDelete?.let { conversation ->
|
||||
val deleteFailedMessage = stringResource(R.string.conversation_delete_failed)
|
||||
val deletedMessage = stringResource(
|
||||
R.string.conversation_deleted,
|
||||
conversation.displayName
|
||||
)
|
||||
val undoLabel = stringResource(R.string.undo)
|
||||
val restoreFailedMessage =
|
||||
stringResource(R.string.conversation_restore_failed)
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingConversationDelete = null },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Delete,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.delete_conversation_title),
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.delete_conversation_message,
|
||||
conversation.displayName
|
||||
),
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
pendingConversationDelete = null
|
||||
sheetScope.launch {
|
||||
val deletion = viewModel.deletePrivateConversation(
|
||||
conversation.conversationID
|
||||
)
|
||||
if (deletion == null) {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = deleteFailedMessage
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = deletedMessage,
|
||||
actionLabel = undoLabel,
|
||||
withDismissAction = true,
|
||||
duration = SnackbarDuration.Long
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) {
|
||||
if (!viewModel.restoreDeletedConversation(deletion)) {
|
||||
snackbarHostState.showSnackbar(
|
||||
restoreFailedMessage
|
||||
)
|
||||
}
|
||||
SnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
pendingConversationDelete?.let { conversation ->
|
||||
val deleteFailedMessage = stringResource(R.string.conversation_delete_failed)
|
||||
val deletedMessage = stringResource(
|
||||
R.string.conversation_deleted,
|
||||
conversation.displayName
|
||||
)
|
||||
val undoLabel = stringResource(R.string.undo)
|
||||
val restoreFailedMessage = stringResource(R.string.conversation_restore_failed)
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingConversationDelete = null },
|
||||
icon = { Icon(imageVector = Icons.Outlined.Delete, contentDescription = null) },
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.delete_conversation_title),
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.delete_conversation_message,
|
||||
conversation.displayName
|
||||
),
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
pendingConversationDelete = null
|
||||
scope.launch {
|
||||
val deletion = viewModel.deletePrivateConversation(
|
||||
conversation.conversationID
|
||||
)
|
||||
if (deletion == null) {
|
||||
snackbarHostState.showSnackbar(message = deleteFailedMessage)
|
||||
return@launch
|
||||
}
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = deletedMessage,
|
||||
actionLabel = undoLabel,
|
||||
withDismissAction = true,
|
||||
duration = SnackbarDuration.Long
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) {
|
||||
if (!viewModel.restoreDeletedConversation(deletion)) {
|
||||
snackbarHostState.showSnackbar(restoreFailedMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.delete),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingConversationDelete = null }) {
|
||||
Text(
|
||||
text = stringResource(android.R.string.cancel),
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.delete),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingConversationDelete = null }) {
|
||||
Text(
|
||||
text = stringResource(android.R.string.cancel),
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* People tab body: mesh peers, or geohash participants when a location channel is selected.
|
||||
*
|
||||
* Every connected peer is listed, including ones you already have a conversation with. The old
|
||||
* combined sheet hid those, but only because the conversation list sat directly above the people
|
||||
* list in the same scroll - the exclusion existed to stop one person appearing twice on one screen.
|
||||
* Chats and People are separate tabs now, so that same filter just made anyone you had ever
|
||||
* messaged disappear from People entirely.
|
||||
*/
|
||||
@Composable
|
||||
fun PeopleListContent(
|
||||
viewModel: ChatViewModel,
|
||||
onOpenConversation: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp)
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
||||
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers
|
||||
.collectAsStateWithLifecycle()
|
||||
val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() }
|
||||
|
||||
val peopleCount = when (selectedLocationChannel) {
|
||||
is ChannelID.Location -> geohashPeople.size
|
||||
else -> connectedPeers.count { it != viewModel.myPeerID }
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = contentPadding
|
||||
) {
|
||||
item(key = "people_section") {
|
||||
when (selectedLocationChannel) {
|
||||
is ChannelID.Location -> {
|
||||
GeohashPeopleList(
|
||||
viewModel = viewModel,
|
||||
onTapPerson = {}
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
PeopleSection(
|
||||
connectedPeers = connectedPeers,
|
||||
peerNicknames = peerNicknames,
|
||||
peerRSSI = peerRSSI,
|
||||
nickname = nickname,
|
||||
colorScheme = colorScheme,
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
wifiAwarePeerIDs = wifiAwarePeerIDs,
|
||||
peopleCount = peopleCount,
|
||||
viewModel = viewModel,
|
||||
onPrivateChatStart = onOpenConversation
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1180,13 +1085,18 @@ private fun ConversationRow(
|
||||
?: viewModel.peerIdentityForMeshPeer(conversation.conversationID)
|
||||
val assignedColor = colorForPeer(peerIdentity, palette)
|
||||
val (baseNameRaw, suffix) = splitSuffix(conversation.displayName)
|
||||
val justNow = stringResource(R.string.time_now)
|
||||
val relativeTime by produceState(
|
||||
initialValue = conversationRelativeTime(conversation.latestMessageAt),
|
||||
conversation.latestMessageAt
|
||||
initialValue = relativeTimeLabel(conversation.latestMessageAt, justNow),
|
||||
conversation.latestMessageAt,
|
||||
justNow
|
||||
) {
|
||||
while (true) {
|
||||
value = conversationRelativeTime(conversation.latestMessageAt)
|
||||
delay(DateUtils.MINUTE_IN_MILLIS)
|
||||
value = relativeTimeLabel(conversation.latestMessageAt, justNow)
|
||||
// Re-tick every 15s, not every minute: a row that says "now" has to become "1 min.
|
||||
// ago" reasonably close to the minute mark, and a full-minute delay lands anywhere
|
||||
// inside it depending on when the row first composed.
|
||||
delay(15_000L)
|
||||
}
|
||||
}
|
||||
val unreadDescription = if (conversation.unreadCount > 0) {
|
||||
@ -1448,13 +1358,23 @@ private fun ConversationRow(
|
||||
}
|
||||
}
|
||||
|
||||
private fun conversationRelativeTime(timestamp: Long): String =
|
||||
DateUtils.getRelativeTimeSpanString(
|
||||
timestamp,
|
||||
System.currentTimeMillis(),
|
||||
DateUtils.MINUTE_IN_MILLIS,
|
||||
DateUtils.FORMAT_ABBREV_RELATIVE
|
||||
).toString()
|
||||
/**
|
||||
* Relative time for a conversation row.
|
||||
*
|
||||
* Anything under a minute is [justNow]: getRelativeTimeSpanString with a minute resolution renders
|
||||
* a message that just arrived as "0 min. ago", which reads like a bug rather than a timestamp.
|
||||
*/
|
||||
private fun relativeTimeLabel(timestamp: Long, justNow: String): String =
|
||||
if (System.currentTimeMillis() - timestamp < DateUtils.MINUTE_IN_MILLIS) {
|
||||
justNow
|
||||
} else {
|
||||
DateUtils.getRelativeTimeSpanString(
|
||||
timestamp,
|
||||
System.currentTimeMillis(),
|
||||
DateUtils.MINUTE_IN_MILLIS,
|
||||
DateUtils.FORMAT_ABBREV_RELATIVE
|
||||
).toString()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PeerItem(
|
||||
@ -1669,13 +1589,12 @@ private fun convertRSSIToSignalStrength(rssi: Int?): Int {
|
||||
/**
|
||||
* Nested Private Chat Sheet - iOS-style nested bottom sheet
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PrivateChatSheet(
|
||||
isPresented: Boolean,
|
||||
fun PrivateChatContent(
|
||||
peerID: String,
|
||||
viewModel: ChatViewModel,
|
||||
onDismiss: () -> Unit
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
|
||||
@ -1799,18 +1718,22 @@ fun PrivateChatSheet(
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "favoriteStarTint"
|
||||
)
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
|
||||
if (isPresented) {
|
||||
BitchatBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Full-screen destination rather than a sheet: the status-bar inset is applied here so the
|
||||
// header sits directly under the system bar and everything below it keeps the sheet's layout.
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(colorScheme.background)
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// Same treatment as the main chat screen: the composer rides above the
|
||||
// keyboard while the header, aligned to the Box, stays put.
|
||||
.windowInsetsPadding(
|
||||
WindowInsets.ime.union(WindowInsets.navigationBars)
|
||||
)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(ChatHeaderHeight))
|
||||
|
||||
@ -1967,11 +1890,8 @@ fun PrivateChatSheet(
|
||||
}
|
||||
}
|
||||
|
||||
val dismiss = LocalSheetDismiss.current
|
||||
CloseButton(onClick = { dismiss?.invoke() ?: onDismiss() })
|
||||
CloseButton(onClick = onBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -408,7 +408,14 @@ fun MessageItem(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val timeFormatter = remember { SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) }
|
||||
val context = LocalContext.current
|
||||
val timeFormat by com.bitchat.android.ui.theme.TimeFormatPreferenceManager.formatFlow
|
||||
.collectAsState()
|
||||
val showSeconds by com.bitchat.android.ui.theme.TimeFormatPreferenceManager.showSecondsFlow
|
||||
.collectAsState()
|
||||
val timeFormatter = remember(context, timeFormat, showSeconds) {
|
||||
chatTimeFormatter(context, timeFormat, showSeconds)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.bitchat.android.utils.DeviceUtils
|
||||
|
||||
/**
|
||||
* Base activity that automatically sets orientation based on device type.
|
||||
* Base activity for the app's own screens: device-appropriate orientation, and the display's
|
||||
* highest refresh rate.
|
||||
*
|
||||
* Tablets can rotate to landscape, phones are locked to portrait.
|
||||
*/
|
||||
abstract class OrientationAwareActivity : AppCompatActivity() {
|
||||
@ -14,6 +19,58 @@ abstract class OrientationAwareActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setOrientationBasedOnDeviceType()
|
||||
requestHighestRefreshRate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the compositor for the fastest mode this panel offers.
|
||||
*
|
||||
* Only modes at the resolution already in use are considered - a panel typically lists its
|
||||
* high refresh rates at more than one resolution, and picking purely by refresh rate would
|
||||
* silently drop the screen to a lower one.
|
||||
*
|
||||
* This is a request, not a guarantee: the system still overrides it for battery saver, for a
|
||||
* user-set refresh-rate preference, and on panels that gate high rates by content.
|
||||
*/
|
||||
private fun requestHighestRefreshRate() {
|
||||
val display: Display? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
display
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
windowManager.defaultDisplay
|
||||
}
|
||||
val current = display?.mode ?: return
|
||||
|
||||
val sameResolution = display.supportedModes.filter {
|
||||
it.physicalWidth == current.physicalWidth &&
|
||||
it.physicalHeight == current.physicalHeight
|
||||
}
|
||||
val fastest = sameResolution.maxByOrNull { it.refreshRate } ?: return
|
||||
|
||||
// No early return when the reported mode already looks fastest. Display.mode can name the
|
||||
// panel's default mode while the compositor is actually driving it slower, and skipping
|
||||
// the request in that case is exactly the situation this method exists to fix.
|
||||
window.attributes = window.attributes.apply {
|
||||
preferredDisplayModeId = fastest.modeId
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// Second, weaker signal. Some vendors honour the float hint when they ignore a
|
||||
// specific mode id.
|
||||
preferredRefreshRate = fastest.refreshRate
|
||||
}
|
||||
}
|
||||
|
||||
// Logged in full because the request is frequently refused and the mode list is the only
|
||||
// way to tell why: a panel whose high rates live at another resolution, a vendor
|
||||
// refresh-rate setting, or a device that simply has one mode.
|
||||
Log.i(
|
||||
TAG,
|
||||
"Requested ${fastest.refreshRate} Hz (mode ${fastest.modeId}); " +
|
||||
"current ${current.refreshRate} Hz at " +
|
||||
"${current.physicalWidth}x${current.physicalHeight}; " +
|
||||
"modes=" + display.supportedModes.joinToString {
|
||||
"${it.physicalWidth}x${it.physicalHeight}@${it.refreshRate}"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun setOrientationBasedOnDeviceType() {
|
||||
@ -25,4 +82,8 @@ abstract class OrientationAwareActivity : AppCompatActivity() {
|
||||
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "OrientationAwareActivity"
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,6 +44,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss
|
||||
@ -193,10 +194,10 @@ private fun buildStatusInfo(
|
||||
else -> Icons.Outlined.NoEncryption
|
||||
}
|
||||
val tint = when {
|
||||
isVerified -> Color(0xFF32D74B)
|
||||
sessionState == "failed" -> Color(0xFFFF3B30)
|
||||
sessionState == "handshaking" -> Color(0xFFFF9500)
|
||||
sessionState == "established" -> Color(0xFF32D74B)
|
||||
isVerified -> MaterialTheme.colorScheme.primary
|
||||
sessionState == "failed" -> MaterialTheme.colorScheme.error
|
||||
sessionState == "handshaking" -> LocalBitchatPalette.current.accentOrange
|
||||
sessionState == "established" -> MaterialTheme.colorScheme.primary
|
||||
else -> accent.copy(alpha = 0.6f)
|
||||
}
|
||||
return SecurityStatusInfo(text, icon, tint)
|
||||
@ -273,9 +274,9 @@ private fun SecurityVerificationActions(
|
||||
if (isVerified) {
|
||||
VerificationStatusRow(
|
||||
icon = Icons.Filled.Verified,
|
||||
iconTint = Color(0xFF32D74B),
|
||||
iconTint = MaterialTheme.colorScheme.primary,
|
||||
text = stringResource(R.string.fingerprint_verified_label),
|
||||
textTint = Color(0xFF32D74B)
|
||||
textTint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.fingerprint_verified_message),
|
||||
@ -289,8 +290,8 @@ private fun SecurityVerificationActions(
|
||||
Button(
|
||||
onClick = { fingerprint?.let(onUnverify) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFFFF3B30),
|
||||
contentColor = Color.White
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
@ -303,9 +304,9 @@ private fun SecurityVerificationActions(
|
||||
} else {
|
||||
VerificationStatusRow(
|
||||
icon = Icons.Filled.Warning,
|
||||
iconTint = Color(0xFFFF9500),
|
||||
iconTint = LocalBitchatPalette.current.accentOrange,
|
||||
text = stringResource(R.string.fingerprint_not_verified_label),
|
||||
textTint = Color(0xFFFF9500)
|
||||
textTint = LocalBitchatPalette.current.accentOrange
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.fingerprint_not_verified_message_fmt, displayName),
|
||||
@ -320,8 +321,8 @@ private fun SecurityVerificationActions(
|
||||
Button(
|
||||
onClick = { onVerify(fingerprint) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF34C759),
|
||||
contentColor = Color.White
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
@ -425,7 +426,7 @@ private fun FingerprintBlock(
|
||||
Text(
|
||||
text = stringResource(R.string.fingerprint_pending),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = BitchatFontFamily),
|
||||
color = Color(0xFFFF9500),
|
||||
color = LocalBitchatPalette.current.accentOrange,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.QrCodeScanner
|
||||
import android.graphics.Bitmap
|
||||
import com.bitchat.android.hotspot.QrCodeGenerator
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
@ -64,8 +64,6 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.set
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
@ -81,9 +79,6 @@ import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.common.BitMatrix
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
@ -101,6 +96,7 @@ fun VerificationSheet(
|
||||
val accent = MaterialTheme.colorScheme.primary
|
||||
|
||||
var selectedTab by remember { mutableStateOf(0) } // 0 = My Code, 1 = Scan
|
||||
var scanStatus by remember { mutableStateOf<Int?>(null) }
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
val npub = remember { viewModel.getCurrentNpub() }
|
||||
|
||||
@ -177,10 +173,20 @@ fun VerificationSheet(
|
||||
)
|
||||
1 -> ScanTabContent(
|
||||
accent = accent,
|
||||
statusRes = scanStatus,
|
||||
// Every rejection path used to return silently, so a scan that could not
|
||||
// work looked identical to one the camera never saw. Each outcome now says
|
||||
// which of the three things went wrong.
|
||||
onScan = { code ->
|
||||
val qr = VerificationService.verifyScannedQR(code)
|
||||
if (qr != null && viewModel.beginQRVerification(qr)) {
|
||||
selectedTab = 0
|
||||
scanStatus = when {
|
||||
qr == null -> R.string.verify_scan_invalid
|
||||
!viewModel.beginQRVerification(qr) ->
|
||||
R.string.verify_scan_peer_not_connected
|
||||
else -> {
|
||||
selectedTab = 0
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@ -318,6 +324,7 @@ private fun MyQrTabContent(
|
||||
@Composable
|
||||
private fun ScanTabContent(
|
||||
accent: Color,
|
||||
statusRes: Int?,
|
||||
onScan: (String) -> Unit
|
||||
) {
|
||||
val permissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
|
||||
@ -364,6 +371,20 @@ private fun ScanTabContent(
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = statusRes?.let { stringResource(it) }
|
||||
?: stringResource(R.string.verify_scan_requires_connection),
|
||||
color = if (statusRes != null) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@ -479,7 +500,7 @@ private fun ScannerView(
|
||||
@Composable
|
||||
private fun QRCodeImage(data: String, size: Dp) {
|
||||
val sizePx = with(LocalDensity.current) { size.toPx().toInt() }
|
||||
val bitmap = remember(data, sizePx) { generateQrBitmap(data, sizePx) }
|
||||
val bitmap = remember(data, sizePx) { QrCodeGenerator.generateQrBitmap(data, sizePx) }
|
||||
if (bitmap != null) {
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
@ -489,29 +510,6 @@ private fun QRCodeImage(data: String, size: Dp) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateQrBitmap(data: String, sizePx: Int): Bitmap? {
|
||||
if (data.isBlank() || sizePx <= 0) return null
|
||||
return try {
|
||||
val matrix = QRCodeWriter().encode(data, BarcodeFormat.QR_CODE, sizePx, sizePx)
|
||||
bitmapFromMatrix(matrix)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun bitmapFromMatrix(matrix: BitMatrix): Bitmap {
|
||||
val width = matrix.width
|
||||
val height = matrix.height
|
||||
val bitmap = createBitmap(width, height)
|
||||
for (x in 0 until width) {
|
||||
for (y in 0 until height) {
|
||||
bitmap[x, y] =
|
||||
if (matrix[x, y]) android.graphics.Color.BLACK else android.graphics.Color.WHITE
|
||||
}
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private class QRCodeAnalyzer(
|
||||
private val onCode: (String) -> Unit
|
||||
) : ImageAnalysis.Analyzer {
|
||||
|
||||
@ -15,16 +15,15 @@ import androidx.compose.ui.graphics.Color
|
||||
data class BitchatPalette(
|
||||
// MARK: - Form controls
|
||||
/**
|
||||
* Resting border for text inputs. Deliberately a neutral grey rather than the green-tinted
|
||||
* Material outline: the composer is the one surface the user stares at while typing.
|
||||
* Resting border for text inputs. A touch darker than the Material outline: the composer is
|
||||
* the one surface the user stares at while typing, so it needs to read as a distinct field.
|
||||
*/
|
||||
val inputOutline: Color,
|
||||
/** Border for a focused text input. A step brighter, still neutral. */
|
||||
val inputOutlineFocused: Color,
|
||||
/**
|
||||
* Fill for text inputs. Near-black / near-white and completely untinted, for the same reason
|
||||
* as [inputOutline] — and because the composer sits on top of a green-tinted scrim, so any
|
||||
* tint of its own compounds into something muddy.
|
||||
* Fill for text inputs. A step off the chat background so the field has an edge even where
|
||||
* the outline is faint.
|
||||
*/
|
||||
val inputSurface: Color,
|
||||
/** Fill for a focused text input. A barely perceptible lift. */
|
||||
@ -49,24 +48,24 @@ data class BitchatPalette(
|
||||
)
|
||||
|
||||
val DarkBitchatPalette = BitchatPalette(
|
||||
inputOutline = Color(0xFF333635),
|
||||
inputOutlineFocused = Color(0xFF5A605D),
|
||||
inputSurface = Color(0xFF0B0B0B),
|
||||
inputSurfaceFocused = Color(0xFF151515),
|
||||
inputButton = Color(0xFF1E1E1E),
|
||||
textTertiary = Color(0xFF6B776B),
|
||||
inputOutline = Color(0xFF33333A),
|
||||
inputOutlineFocused = Color(0xFF5A5A64),
|
||||
inputSurface = Color(0xFF121215),
|
||||
inputSurfaceFocused = Color(0xFF1B1B20),
|
||||
inputButton = Color(0xFF25252B),
|
||||
textTertiary = Color(0xFF71717C),
|
||||
accentOrange = Color(0xFFFF9F0A),
|
||||
accentPurple = Color(0xFFBF5AF2),
|
||||
peerColors = PeerColorStyle.Dark,
|
||||
)
|
||||
|
||||
val LightBitchatPalette = BitchatPalette(
|
||||
inputOutline = Color(0xFFCFD3D1),
|
||||
inputOutlineFocused = Color(0xFF8E9490),
|
||||
inputSurface = Color(0xFFFAFAFA),
|
||||
inputSurfaceFocused = Color(0xFFF2F2F2),
|
||||
inputButton = Color(0xFFE8E8E8),
|
||||
textTertiary = Color(0xFF757F75),
|
||||
inputOutline = Color(0xFFD1D1D8),
|
||||
inputOutlineFocused = Color(0xFF8E8E9A),
|
||||
inputSurface = Color(0xFFFAFAFC),
|
||||
inputSurfaceFocused = Color(0xFFF2F2F5),
|
||||
inputButton = Color(0xFFE9E9EE),
|
||||
textTertiary = Color(0xFF7A7A85),
|
||||
accentOrange = Color(0xFFFF9500),
|
||||
accentPurple = Color(0xFFAF52DE),
|
||||
peerColors = PeerColorStyle.Light,
|
||||
|
||||
@ -56,7 +56,7 @@ internal object ChatVisualTokens {
|
||||
* Author-colour wash inside a bubble. Matches the mention-chip treatment so a tinted
|
||||
* bubble stays legible on both the near-black and near-white chat surfaces.
|
||||
*/
|
||||
const val BubbleBackgroundAlpha: Float = 0.18f
|
||||
const val BubbleBackgroundAlpha: Float = 0.24f
|
||||
|
||||
/** Author-colour hairline around a bubble; stronger than the fill so the shape reads. */
|
||||
const val BubbleBorderAlpha: Float = 0.38f
|
||||
|
||||
@ -19,6 +19,11 @@ import androidx.compose.ui.platform.LocalView
|
||||
|
||||
// Standard UI semantics live in Material so stock components and custom Bitchat composables
|
||||
// share one source of truth. LocalBitchatPalette below only supplies app-specific extra colors.
|
||||
//
|
||||
// The neutrals are true greys. Green survives only as the accent - primary, and the peer hues -
|
||||
// because a green cast on every surface is what made the app read as a terminal rather than a
|
||||
// chat client. The surfaceContainer* roles are spelled out because M3's defaults for them are
|
||||
// derived from its purple baseline, which shows up in dialogs, snackbars and the nav bar.
|
||||
internal val DarkBitchatColorScheme = darkColorScheme(
|
||||
primary = Color(0xFF32D74B),
|
||||
onPrimary = Color.Black,
|
||||
@ -30,14 +35,19 @@ internal val DarkBitchatColorScheme = darkColorScheme(
|
||||
onSecondaryContainer = Color(0xFFC2E0FF),
|
||||
tertiary = DarkBitchatPalette.accentOrange,
|
||||
onTertiary = Color.Black,
|
||||
background = Color(0xFF000000),
|
||||
onBackground = Color(0xFFF5F5F5),
|
||||
surface = Color(0xFF0E150E),
|
||||
onSurface = Color(0xFFF5F5F5),
|
||||
surfaceVariant = Color(0xFF182118),
|
||||
onSurfaceVariant = Color(0xFF9AA69A),
|
||||
outline = Color(0xFF2A3A2A),
|
||||
outlineVariant = Color(0xFF1C271C),
|
||||
background = Color(0xFF0B0B0D),
|
||||
onBackground = Color(0xFFF3F3F5),
|
||||
surface = Color(0xFF16161A),
|
||||
onSurface = Color(0xFFF3F3F5),
|
||||
surfaceVariant = Color(0xFF1F1F24),
|
||||
onSurfaceVariant = Color(0xFF9C9CA6),
|
||||
surfaceContainerLowest = Color(0xFF08080A),
|
||||
surfaceContainerLow = Color(0xFF121215),
|
||||
surfaceContainer = Color(0xFF16161A),
|
||||
surfaceContainerHigh = Color(0xFF1F1F24),
|
||||
surfaceContainerHighest = Color(0xFF29292F),
|
||||
outline = Color(0xFF2E2E34),
|
||||
outlineVariant = Color(0xFF212126),
|
||||
error = Color(0xFFFF453A),
|
||||
onError = Color.Black
|
||||
)
|
||||
@ -54,13 +64,18 @@ internal val LightBitchatColorScheme = lightColorScheme(
|
||||
tertiary = LightBitchatPalette.accentOrange,
|
||||
onTertiary = Color.Black,
|
||||
background = Color(0xFFFFFFFF),
|
||||
onBackground = Color(0xFF131A13),
|
||||
surface = Color(0xFFF2F6F2),
|
||||
onSurface = Color(0xFF131A13),
|
||||
surfaceVariant = Color(0xFFE7EDE7),
|
||||
onSurfaceVariant = Color(0xFF4C574C),
|
||||
outline = Color(0xFFCBD6CB),
|
||||
outlineVariant = Color(0xFFDEE6DE),
|
||||
onBackground = Color(0xFF15151A),
|
||||
surface = Color(0xFFF4F4F6),
|
||||
onSurface = Color(0xFF15151A),
|
||||
surfaceVariant = Color(0xFFEAEAEE),
|
||||
onSurfaceVariant = Color(0xFF55555F),
|
||||
surfaceContainerLowest = Color(0xFFFFFFFF),
|
||||
surfaceContainerLow = Color(0xFFF8F8FA),
|
||||
surfaceContainer = Color(0xFFF4F4F6),
|
||||
surfaceContainerHigh = Color(0xFFEEEEF2),
|
||||
surfaceContainerHighest = Color(0xFFE7E7EC),
|
||||
outline = Color(0xFFD6D6DC),
|
||||
outlineVariant = Color(0xFFE6E6EB),
|
||||
error = Color(0xFFD70015),
|
||||
onError = Color.White
|
||||
)
|
||||
|
||||
56
app/src/main/java/com/bitchat/android/ui/theme/TimeFormat.kt
Normal file
56
app/src/main/java/com/bitchat/android/ui/theme/TimeFormat.kt
Normal file
@ -0,0 +1,56 @@
|
||||
package com.bitchat.android.ui.theme
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Clock style for transcript timestamps.
|
||||
*
|
||||
* [System] follows the device's own 12/24-hour setting, which is the right default - a phone set
|
||||
* to 12-hour showing `13:42` in one app and `1:42 PM` everywhere else reads as a bug. The explicit
|
||||
* options exist because some people want a 24-hour transcript on a 12-hour phone regardless.
|
||||
*/
|
||||
enum class TimeFormatPreference {
|
||||
System,
|
||||
TwelveHour,
|
||||
TwentyFourHour
|
||||
}
|
||||
|
||||
/**
|
||||
* SharedPreferences-backed clock settings with StateFlows. Mirrors [ThemePreferenceManager].
|
||||
*
|
||||
* Both values live in one object because they are one user-facing concern - how a timestamp is
|
||||
* written - and a second singleton would only duplicate the init/set plumbing.
|
||||
*/
|
||||
object TimeFormatPreferenceManager {
|
||||
private const val PREFS_NAME = "bitchat_settings"
|
||||
private const val KEY_TIME_FORMAT = "time_format"
|
||||
private const val KEY_SHOW_SECONDS = "time_show_seconds"
|
||||
|
||||
private val _formatFlow = MutableStateFlow(TimeFormatPreference.System)
|
||||
val formatFlow: StateFlow<TimeFormatPreference> = _formatFlow
|
||||
|
||||
private val _showSecondsFlow = MutableStateFlow(false)
|
||||
val showSecondsFlow: StateFlow<Boolean> = _showSecondsFlow
|
||||
|
||||
fun init(context: Context) {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val saved = prefs.getString(KEY_TIME_FORMAT, TimeFormatPreference.System.name)
|
||||
_formatFlow.value = runCatching { TimeFormatPreference.valueOf(saved!!) }
|
||||
.getOrDefault(TimeFormatPreference.System)
|
||||
_showSecondsFlow.value = prefs.getBoolean(KEY_SHOW_SECONDS, false)
|
||||
}
|
||||
|
||||
fun set(context: Context, preference: TimeFormatPreference) {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit().putString(KEY_TIME_FORMAT, preference.name).apply()
|
||||
_formatFlow.value = preference
|
||||
}
|
||||
|
||||
fun setShowSeconds(context: Context, showSeconds: Boolean) {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit().putBoolean(KEY_SHOW_SECONDS, showSeconds).apply()
|
||||
_showSecondsFlow.value = showSeconds
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,11 @@
|
||||
<string name="no_one_connected">No one connected</string>
|
||||
<string name="emergency_clear_hint">Triple tap to clear all data</string>
|
||||
<string name="your_network">Network</string>
|
||||
|
||||
<!-- Bottom tab bar -->
|
||||
<string name="tab_chats">Chats</string>
|
||||
<string name="tab_public">Public</string>
|
||||
<string name="time_now">now</string>
|
||||
|
||||
<!-- Battery Optimization Strings -->
|
||||
<string name="battery_optimization_detected">Battery Optimization Detected</string>
|
||||
@ -171,8 +176,15 @@
|
||||
<!-- About sheet: section labels -->
|
||||
<string name="about_section_about">About</string>
|
||||
<string name="about_section_theme">Theme</string>
|
||||
<string name="about_section_clock">Clock</string>
|
||||
<string name="about_section_settings">Settings</string>
|
||||
|
||||
<!-- Settings: transcript clock -->
|
||||
<string name="clock_12_hour">12-hour</string>
|
||||
<string name="clock_24_hour">24-hour</string>
|
||||
<string name="clock_show_seconds">Show seconds</string>
|
||||
<string name="clock_show_seconds_subtitle">Add seconds to message timestamps, e.g. 13:45:40</string>
|
||||
|
||||
<!-- About sheet: feature list -->
|
||||
<string name="about_no_tracking_title">No Tracking</string>
|
||||
<string name="about_no_tracking_desc">No servers, accounts, or data collection.</string>
|
||||
@ -475,6 +487,9 @@
|
||||
<string name="debug_recent_scan_results">Recent scan results</string>
|
||||
|
||||
<string name="verify_title">Verify</string>
|
||||
<string name="verify_scan_requires_connection">Verifies someone already on your mesh. Both phones need Bluetooth on and to be in range — scanning does not connect you.</string>
|
||||
<string name="verify_scan_invalid">Not a valid bitchat code, or it expired. Codes are good for 5 minutes — ask them to reopen theirs.</string>
|
||||
<string name="verify_scan_peer_not_connected">That person is not connected to you yet. Wait for them to appear under People, then scan again.</string>
|
||||
<string name="verify_my_qr_title">Scan to verify me</string>
|
||||
<string name="verify_scan_prompt_friend">Scan someone else\'s QR</string>
|
||||
<string name="verify_scan_someone">Scan someone else\'s QR</string>
|
||||
|
||||
@ -489,7 +489,7 @@ class ChatUIUtilsTest {
|
||||
|
||||
@Test
|
||||
fun `material owns standard text while Bitchat palette owns peer chroma`() {
|
||||
assertEquals(Color(0xFFF5F5F5), DarkBitchatColorScheme.onSurface)
|
||||
assertEquals(Color(0xFFF3F3F5), DarkBitchatColorScheme.onSurface)
|
||||
assertTrue(LightBitchatColorScheme.onSurface != DarkBitchatColorScheme.onSurface)
|
||||
assertTrue(
|
||||
LightBitchatPalette.peerColors != DarkBitchatPalette.peerColors
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user