diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index 7b5926ef..f800f8a8 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -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) { } diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 6f80052a..e7e4e033 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -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) diff --git a/app/src/main/java/com/bitchat/android/hotspot/QrCodeGenerator.kt b/app/src/main/java/com/bitchat/android/hotspot/QrCodeGenerator.kt index 69c2bdf1..a08aebae 100644 --- a/app/src/main/java/com/bitchat/android/hotspot/QrCodeGenerator.kt +++ b/app/src/main/java/com/bitchat/android/hotspot/QrCodeGenerator.kt @@ -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) + } } /** diff --git a/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt b/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt index 96fa15b8..f711b1cc 100644 --- a/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt +++ b/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt @@ -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) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 54ab0318..909da8f0 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -49,8 +49,10 @@ import androidx.compose.material.icons.filled.UnfoldMore import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Wifi import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -298,13 +300,20 @@ private fun SettingsToggleRow( * Apple-like About/Settings Sheet with high-quality design * Professional UX optimized for checkout scenarios */ +/** + * App info and settings, without a container. + * + * Lifted out of [AboutSheet] so the same content can be a bottom-tab destination as well as a + * sheet. The Info/Settings segmented bar it already had is kept - the tab just opens on Settings. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun AboutSheet( - isPresented: Boolean, - onDismiss: () -> Unit, +fun AboutContent( + modifier: Modifier = Modifier, + initialTab: AboutTab = AboutTab.Info, onShowDebug: (() -> Unit)? = null, - modifier: Modifier = Modifier + contentPadding: PaddingValues = PaddingValues(top = 72.dp, bottom = 32.dp), + onClose: (() -> Unit)? = null ) { val context = LocalContext.current @@ -331,7 +340,7 @@ fun AboutSheet( val colorScheme = MaterialTheme.colorScheme val palette = LocalBitchatPalette.current - var selectedTab by remember { mutableStateOf(AboutTab.Info) } + var selectedTab by rememberSaveable { mutableStateOf(initialTab) } val supportedLanguages = remember(context) { LanguagePreferenceManager.supportedLanguages(context) } @@ -340,951 +349,1049 @@ fun AboutSheet( } var showLanguagePicker by remember { mutableStateOf(false) } - if (isPresented) { - BitchatBottomSheet( - modifier = modifier, - onDismissRequest = onDismiss, + Box(modifier = modifier.fillMaxWidth()) { + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(0.dp) ) { - Box(modifier = Modifier.fillMaxWidth()) { - LazyColumn( - state = lazyListState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp), - verticalArrangement = Arrangement.spacedBy(0.dp) - ) { - // Header Section - App Identity - item(key = "hero") { - AboutHero(versionName = versionName ?: "") + // Header Section - App Identity + item(key = "hero") { + AboutHero(versionName = versionName ?: "") + } + + item(key = "tabs") { + AboutTabBar( + selected = selectedTab, + onSelect = { selectedTab = it }, + modifier = Modifier.padding(top = 24.dp) + ) + } + + if (selectedTab == AboutTab.Info) { + // What the app is, then how to drive it. Both are reference material a + // new user reads once, so they belong on the same tab. + item(key = "features") { + Column { + AboutSectionLabel(text = stringResource(R.string.about_section_about)) + AboutFeatureCard() } + } - item(key = "tabs") { - AboutTabBar( - selected = selectedTab, - onSelect = { selectedTab = it }, - modifier = Modifier.padding(top = 24.dp) - ) - } + item(key = "how_to_use") { + AboutHowToUseSection() + } + } - if (selectedTab == AboutTab.Info) { - // What the app is, then how to drive it. Both are reference material a - // new user reads once, so they belong on the same tab. - item(key = "features") { - Column { - AboutSectionLabel(text = stringResource(R.string.about_section_about)) - AboutFeatureCard() - } - } - - item(key = "how_to_use") { - AboutHowToUseSection() - } - } - - if (selectedTab == AboutTab.Settings) { - // Appearance Section - item(key = "appearance") { - Column { - AboutSectionLabel(text = stringResource(R.string.about_section_theme)) - val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState() - val chatUiMode by com.bitchat.android.ui.theme.ChatUiModeManager.modeFlow.collectAsState() - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding), - color = colorScheme.surface, - shape = AboutCardShape + if (selectedTab == AboutTab.Settings) { + // Appearance Section + item(key = "appearance") { + Column { + AboutSectionLabel(text = stringResource(R.string.about_section_theme)) + val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState() + val chatUiMode by com.bitchat.android.ui.theme.ChatUiModeManager.modeFlow.collectAsState() + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - ThemeChip( - label = stringResource(R.string.about_system), - selected = themePref.isSystem, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) }, - modifier = Modifier.weight(1f) - ) - ThemeChip( - label = stringResource(R.string.about_light), - selected = themePref.isLight, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) }, - modifier = Modifier.weight(1f) - ) - ThemeChip( - label = stringResource(R.string.about_dark), - selected = themePref.isDark, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) }, - modifier = Modifier.weight(1f) - ) - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - ThemeChip( - label = stringResource(R.string.chat_ui_bubbles), - selected = chatUiMode.isBubbles, - onClick = { com.bitchat.android.ui.theme.ChatUiModeManager.set(context, com.bitchat.android.ui.theme.ChatUiMode.Bubbles) }, - modifier = Modifier.weight(1f) - ) - ThemeChip( - label = stringResource(R.string.chat_ui_matrix), - selected = chatUiMode.isMatrix, - onClick = { com.bitchat.android.ui.theme.ChatUiModeManager.set(context, com.bitchat.android.ui.theme.ChatUiMode.Matrix) }, - modifier = Modifier.weight(1f) - ) - } - } + ThemeChip( + label = stringResource(R.string.about_system), + selected = themePref.isSystem, + onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) }, + modifier = Modifier.weight(1f) + ) + ThemeChip( + label = stringResource(R.string.about_light), + selected = themePref.isLight, + onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) }, + modifier = Modifier.weight(1f) + ) + ThemeChip( + label = stringResource(R.string.about_dark), + selected = themePref.isDark, + onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) }, + modifier = Modifier.weight(1f) + ) } - } - } - - item(key = "language") { - val selectedLanguageName = supportedLanguages - .firstOrNull { it.languageTag == selectedLanguageTag } - ?.endonym - ?: stringResource(R.string.about_system_default) - - Column { - AboutSectionLabel(text = stringResource(R.string.about_language)) - BoxWithConstraints( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding), + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.surface, - shape = AboutCardShape, - ) { - LanguageSettingsRow( - selectedLanguageName = selectedLanguageName, - onClick = { showLanguagePicker = true }, - ) - } - DropdownMenu( - expanded = showLanguagePicker, - onDismissRequest = { showLanguagePicker = false }, - modifier = Modifier.width(maxWidth), - ) { - LanguageMenuItem( - label = stringResource(R.string.about_system_default), - selected = selectedLanguageTag.isEmpty(), - onClick = { - showLanguagePicker = false - if (selectedLanguageTag.isNotEmpty()) { - selectedLanguageTag = "" - LanguagePreferenceManager.setLanguage("") - } - }, - ) - HorizontalDivider() - supportedLanguages.forEach { language -> - LanguageMenuItem( - label = language.endonym, - selected = selectedLanguageTag == language.languageTag, - onClick = { - showLanguagePicker = false - if (language.languageTag != selectedLanguageTag) { - selectedLanguageTag = language.languageTag - LanguagePreferenceManager.setLanguage(language.languageTag) - } - }, - ) - } - } - } - } - } - - // Settings Section - Unified Card with Toggles - item(key = "settings") { - LaunchedEffect(Unit) { PoWPreferenceManager.init(context) } - val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() - val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() - var backgroundEnabled by remember { mutableStateOf(com.bitchat.android.service.MeshServicePreferences.isBackgroundEnabled(true)) } - var liveVoiceEnabled by remember { - mutableStateOf(com.bitchat.android.features.voice.LiveVoicePreferences.isEnabled(context)) - } - val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) } - val torProvider = remember { ArtiTorManager.getInstance() } - val torStatus by torProvider.statusFlow.collectAsState() - val torAvailable = remember { torProvider.isTorAvailable() } - - Column { - AboutSectionLabel(text = stringResource(R.string.about_section_settings)) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding), - color = colorScheme.surface, - shape = AboutCardShape - ) { - Column { - // Background Mode Toggle - SettingsToggleRow( - icon = Icons.Filled.Bluetooth, - title = stringResource(R.string.about_background_title), - subtitle = stringResource(R.string.about_background_desc), - checked = backgroundEnabled, - onCheckedChange = { enabled -> - backgroundEnabled = enabled - com.bitchat.android.service.MeshServicePreferences.setBackgroundEnabled(enabled) - if (!enabled) { - com.bitchat.android.service.MeshForegroundService.stop(context) - } else { - com.bitchat.android.service.MeshForegroundService.start(context) - } - } - ) - - HorizontalDivider( - modifier = Modifier.padding(start = 54.dp), - thickness = 1.dp, - color = colorScheme.outlineVariant - ) - - SettingsToggleRow( - icon = Icons.Filled.Mic, - title = "Live push-to-talk", - subtitle = "Play voice bursts live on the mesh; voice notes are always sent on release", - checked = liveVoiceEnabled, - onCheckedChange = { enabled -> - liveVoiceEnabled = enabled - com.bitchat.android.features.voice.LiveVoicePreferences.setEnabled(context, enabled) - } - ) - - HorizontalDivider( - modifier = Modifier.padding(start = 54.dp), - thickness = 1.dp, - color = colorScheme.outlineVariant - ) - - // Proof of Work Toggle - SettingsToggleRow( - icon = Icons.Filled.Speed, - title = stringResource(R.string.about_pow), - subtitle = stringResource(R.string.about_pow_tip), - checked = powEnabled, - onCheckedChange = { PoWPreferenceManager.setPowEnabled(it) } - ) - - HorizontalDivider( - modifier = Modifier.padding(start = 54.dp), - thickness = 1.dp, - color = colorScheme.outlineVariant - ) - - // Tor Toggle - SettingsToggleRow( - icon = Icons.Filled.Security, - title = stringResource(R.string.about_tor_title), - subtitle = stringResource(R.string.about_tor_route), - checked = torMode.value == TorMode.ON, - onCheckedChange = { enabled -> - if (torAvailable) { - torMode.value = if (enabled) TorMode.ON else TorMode.OFF - TorPreferenceManager.set(context, torMode.value) - } - }, - enabled = torAvailable, - statusIndicator = if (torMode.value == TorMode.ON) { - { - val statusColor = when { - torStatus.running && torStatus.bootstrapPercent >= 100 -> colorScheme.primary - torStatus.running -> palette.accentOrange - else -> colorScheme.error - } - Surface( - color = statusColor, - shape = CircleShape, - modifier = Modifier.size(8.dp) - ) {} - } - } else null - ) - - HorizontalDivider( - modifier = Modifier.padding(start = 56.dp), - color = colorScheme.outline.copy(alpha = 0.12f) - ) - - // === Prepare App for Sharing Section === - val apkViewModel: ApkDownloadViewModel = viewModel() - val apkUiState by apkViewModel.state.collectAsStateWithLifecycle() - val apkStatus = apkUiState.apkStatus - val releaseStatus = apkUiState.releaseStatus - val downloadProgress = apkUiState.downloadProgress - val shareableApk = when (apkStatus) { - is ApkPreparationStatus.Ready -> apkStatus - is ApkPreparationStatus.Downloading -> - apkStatus.shareableFallback - else -> null - } - val availableUpdate = (releaseStatus as? ApkReleaseStatus.Known) - ?.takeIf { it.isNewerThanSharedApk } - - // Handle one-shot effects (navigation, toasts, share intents) - LaunchedEffect(Unit) { - apkViewModel.onEvent(ApkUiEvent.CheckStatus) - apkViewModel.effect.collect { effect -> - when (effect) { - is ApkUiEffect.NavigateToHotspot -> { - val intent = Intent(context, HotspotActivity::class.java) - intent.putExtra(HotspotActivity.EXTRA_APK_PATH, effect.apkPath) - context.startActivity(intent) - } - is ApkUiEffect.ShareApk -> { - val intent = Intent(Intent.ACTION_SEND).apply { - type = "application/vnd.android.package-archive" - putExtra(Intent.EXTRA_STREAM, effect.apkUri) - clipData = android.content.ClipData.newRawUri("", effect.apkUri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - val chooser = Intent.createChooser(intent, effect.chooserTitle).apply { - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - context.startActivity(chooser) - } - is ApkUiEffect.ShowToast -> { - Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() - } - } - } - } - - // Prepare App for Sharing Row - Row( - modifier = Modifier - .fillMaxWidth() - // Enabled by the same mapping that decides what the tap - // does, so the row can never look tappable and do - // nothing. - .clickable( - enabled = prepareRowTapAction( - apkStatus, - releaseStatus - ) != null - ) { - apkViewModel.onEvent(ApkUiEvent.PrepareRowClicked) - } - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = if (shareableApk != null) { - Icons.Default.Share - } else { - Icons.Default.CloudDownload - }, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(22.dp) - ) - - Spacer(modifier = Modifier.width(14.dp)) - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = if (shareableApk != null) { - stringResource(R.string.prepare_apk_ready_title) - } else { - stringResource(R.string.prepare_apk_title) - }, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = colorScheme.onSurface - ) - if (availableUpdate != null) { - TooltipBox( - positionProvider = TooltipDefaults - .rememberTooltipPositionProvider(), - tooltip = { - PlainTooltip { - Text( - stringResource( - R.string.prepare_apk_update_available, - availableUpdate.version - ) - ) - } - }, - state = rememberTooltipState() - ) { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = stringResource( - R.string.prepare_apk_update_warning - ), - tint = colorScheme.tertiary, - modifier = Modifier - .padding(start = 6.dp) - .size(18.dp) - .clickable { - apkViewModel.onEvent( - ApkUiEvent.DownloadUniversalClicked - ) - } - ) - } - } - } - Text( - text = when (val status = apkStatus) { - is ApkPreparationStatus.Loading -> stringResource(R.string.checking) - is ApkPreparationStatus.NotDownloaded -> - stringResource( - R.string.prepare_apk_status_not_downloaded - ) - is ApkPreparationStatus.Ready -> { - val source = when { - status.source == UniversalApkManager.ApkSource.DOWNLOADED -> - stringResource(R.string.prepare_apk_source_downloaded) - status.variant == ShareableApkVariant.ARM64 -> - stringResource(R.string.prepare_apk_source_installed_arm64) - else -> - stringResource(R.string.prepare_apk_source_installed) - } - stringResource( - R.string.prepare_apk_ready_detail, - status.version, - status.sizeMB, - source - ) - } - is ApkPreparationStatus.Downloading -> - // Only the transfer has a percentage worth - // showing; the other phases are named - // instead of pretending to be at 0%. - if (status.phase.hasMeasurableProgress) { - stringResource(R.string.prepare_apk_status_downloading, downloadProgress) - } else { - stringResource(downloadPhaseLabel(status.phase)) - } - is ApkPreparationStatus.Resumable -> - stringResource( - R.string.prepare_apk_status_resumable, - context.resolveApkFailureMessage( - status.failure - ), - status.progressPercent - ) - is ApkPreparationStatus.Error -> - context.resolveApkFailureMessage( - status.failure - ) - }, - style = MaterialTheme.typography.bodySmall, - color = when (apkStatus) { - is ApkPreparationStatus.Error -> colorScheme.error - is ApkPreparationStatus.Resumable -> colorScheme.primary - else -> colorScheme.onSurface.copy(alpha = 0.6f) - }, - lineHeight = 16.sp - ) - - // Progress lives in the column, not the trailing slot, - // which leaves that slot free for a single control. - ApkDownloadProgressBar( - status = apkStatus, - progressPercent = downloadProgress - ) - } - - // One control, one width, in every state. The progress - // readout moved into the column above, so nothing else - // competes for this slot. - when (apkStatus) { - is ApkPreparationStatus.Downloading -> - ApkPrepareRowIconButton( - icon = Icons.Default.Close, - description = stringResource( - R.string.prepare_apk_stop - ), - onClick = { - apkViewModel.onEvent( - ApkUiEvent.CancelDownload - ) - } - ) - is ApkPreparationStatus.Ready -> { - if ( - apkStatus.source == - UniversalApkManager.ApkSource.INSTALLED - ) { - ApkPrepareRowIconButton( - icon = Icons.Default.CloudDownload, - description = stringResource( - R.string.prepare_apk_get_universal - ), - onClick = { - apkViewModel.onEvent( - ApkUiEvent.DownloadUniversalClicked - ) - }, - tint = colorScheme.primary - ) - } else if ( - apkStatus.source == - UniversalApkManager.ApkSource.DOWNLOADED - ) { - ApkPrepareRowIconButton( - icon = Icons.Default.Delete, - description = stringResource( - R.string.prepare_apk_button_delete - ), - onClick = { - apkViewModel.onEvent( - ApkUiEvent.DeleteClicked - ) - }, - tint = colorScheme.error - ) - } - } - is ApkPreparationStatus.Resumable, - is ApkPreparationStatus.Error -> - ApkPrepareRowIconButton( - icon = Icons.Default.Refresh, - description = stringResource( - R.string.prepare_apk_retry - ), - onClick = { - apkViewModel.onEvent( - ApkUiEvent.PrepareRowClicked - ) - }, - tint = colorScheme.primary - ) - else -> {} - } - } - - // Prepare Dialog - if (apkUiState.showPrepareDialog) { - AlertDialog( - onDismissRequest = { apkViewModel.onEvent(ApkUiEvent.DismissPrepareDialog) }, - title = { - Text( - text = stringResource( - if (availableUpdate != null) { - R.string.prepare_apk_update_dialog_title - } else { - R.string.prepare_apk_dialog_title - } - ), - style = MaterialTheme.typography.titleLarge - ) - }, - text = { - Text( - text = if (availableUpdate != null) { - stringResource( - R.string.prepare_apk_update_dialog_message, - availableUpdate.version, - availableUpdate.sizeMB - ) - } else { - stringResource( - R.string.prepare_apk_dialog_message_unknown_size - ) - }, - style = MaterialTheme.typography.bodyMedium - ) - }, - confirmButton = { - Button(onClick = { - apkViewModel.onEvent(ApkUiEvent.ConfirmDownload) - }) { - Text(stringResource(R.string.prepare_apk_dialog_confirm)) - } - }, - dismissButton = { - TextButton(onClick = { apkViewModel.onEvent(ApkUiEvent.DismissPrepareDialog) }) { - Text(stringResource(R.string.cancel)) - } - }, - containerColor = colorScheme.surface - ) - } - - // Delete Dialog - if (apkUiState.showDeleteDialog) { - val sizeMB = (apkStatus as? ApkPreparationStatus.Ready)?.sizeMB ?: 0 - AlertDialog( - onDismissRequest = { apkViewModel.onEvent(ApkUiEvent.DismissDeleteDialog) }, - title = { - Text( - text = stringResource(R.string.prepare_apk_delete_confirm), - style = MaterialTheme.typography.titleLarge - ) - }, - text = { - Text( - text = stringResource(R.string.prepare_apk_delete_message, sizeMB), - style = MaterialTheme.typography.bodyMedium - ) - }, - confirmButton = { - Button( - onClick = { - apkViewModel.onEvent(ApkUiEvent.ConfirmDelete) - }, - colors = androidx.compose.material3.ButtonDefaults.buttonColors( - containerColor = colorScheme.error - ) - ) { - Text( - stringResource( - R.string.prepare_apk_button_delete - ) - ) - } - }, - dismissButton = { - TextButton(onClick = { apkViewModel.onEvent(ApkUiEvent.DismissDeleteDialog) }) { - Text(stringResource(R.string.cancel)) - } - }, - containerColor = colorScheme.surface - ) - } - - // A GitHub update is optional. Keep sharing visible while the - // replacement downloads or while metadata refreshes. - val canShareAPK = shareableApk != null - - AnimatedVisibility( - visible = canShareAPK, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically() - ) { - Column { - HorizontalDivider( - modifier = Modifier.padding(start = 56.dp), - color = colorScheme.outline.copy(alpha = 0.12f) - ) - - // === Share via Hotspot Row === - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - apkViewModel.onEvent(ApkUiEvent.HotspotShareClicked) - } - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Wifi, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(22.dp) - ) - - Spacer(modifier = Modifier.width(14.dp)) - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = stringResource(R.string.hotspot_share_via), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = colorScheme.onSurface - ) - Text( - text = stringResource(R.string.hotspot_share_via_subtitle), - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onSurface.copy(alpha = 0.6f), - lineHeight = 16.sp - ) - } - - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = null, - tint = colorScheme.onSurface.copy(alpha = 0.4f), - modifier = Modifier.size(20.dp) - ) - } - - HorizontalDivider( - modifier = Modifier.padding(start = 56.dp), - color = colorScheme.outline.copy(alpha = 0.12f) - ) - - // === Share via Bluetooth/Email Row (Fallback) === - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { apkViewModel.onEvent(ApkUiEvent.AppShareClicked) } - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Bluetooth, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(22.dp) - ) - - Spacer(modifier = Modifier.width(14.dp)) - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = stringResource(R.string.hotspot_share_other), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = colorScheme.onSurface - ) - Text( - text = stringResource(R.string.hotspot_share_other_subtitle), - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onSurface.copy(alpha = 0.6f), - lineHeight = 16.sp - ) - } - - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = null, - tint = colorScheme.onSurface.copy(alpha = 0.4f), - modifier = Modifier.size(20.dp) - ) - } - - // APK Share Dialog - ApkShareExplanationDialog( - show = apkUiState.showShareApkDialog, - onConfirm = { - apkViewModel.onEvent(ApkUiEvent.ConfirmAppShare) - }, - onDismiss = { apkViewModel.onEvent(ApkUiEvent.DismissShareDialog) } - ) - } - } - - } - } - - // Tor unavailable hint - if (!torAvailable) { - Text( - text = stringResource(R.string.tor_not_available_in_this_build), - fontSize = 12.sp, - fontFamily = BitchatFontFamily, - color = palette.textTertiary, - modifier = Modifier.padding( - start = AboutHorizontalPadding + 16.dp, - top = 8.dp - ) + ThemeChip( + label = stringResource(R.string.chat_ui_bubbles), + selected = chatUiMode.isBubbles, + onClick = { com.bitchat.android.ui.theme.ChatUiModeManager.set(context, com.bitchat.android.ui.theme.ChatUiMode.Bubbles) }, + modifier = Modifier.weight(1f) + ) + ThemeChip( + label = stringResource(R.string.chat_ui_matrix), + selected = chatUiMode.isMatrix, + onClick = { com.bitchat.android.ui.theme.ChatUiModeManager.set(context, com.bitchat.android.ui.theme.ChatUiMode.Matrix) }, + modifier = Modifier.weight(1f) ) } } } + } + } - // PoW Difficulty Slider (when enabled) - item(key = "pow_slider") { - val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() - val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() - - if (powEnabled) { - Column(modifier = Modifier.padding(top = 12.dp)) { - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding), - color = colorScheme.surface, - shape = AboutCardShape - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.about_difficulty), - fontFamily = BitchatFontFamily, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = colorScheme.onSurface + item(key = "clock") { + Column { + AboutSectionLabel(text = stringResource(R.string.about_section_clock)) + val timeFormat by + com.bitchat.android.ui.theme.TimeFormatPreferenceManager.formatFlow + .collectAsState() + val showSeconds by + com.bitchat.android.ui.theme.TimeFormatPreferenceManager.showSecondsFlow + .collectAsState() + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ThemeChip( + label = stringResource(R.string.about_system), + selected = + timeFormat == com.bitchat.android.ui.theme + .TimeFormatPreference.System, + onClick = { + com.bitchat.android.ui.theme.TimeFormatPreferenceManager + .set( + context, + com.bitchat.android.ui.theme + .TimeFormatPreference.System ) - AnimatedCountLabel( - count = powDifficulty, - text = stringResource( - R.string.about_difficulty_value, - powDifficulty, - NostrProofOfWork.estimateMiningTime(powDifficulty) - ), - fontFamily = BitchatFontFamily, - fontSize = 12.sp, - color = colorScheme.onSurfaceVariant + }, + modifier = Modifier.weight(1f) + ) + ThemeChip( + label = stringResource(R.string.clock_12_hour), + selected = + timeFormat == com.bitchat.android.ui.theme + .TimeFormatPreference.TwelveHour, + onClick = { + com.bitchat.android.ui.theme.TimeFormatPreferenceManager + .set( + context, + com.bitchat.android.ui.theme + .TimeFormatPreference.TwelveHour ) - } - - Slider( - value = powDifficulty.toFloat(), - onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) }, - valueRange = 0f..32f, - steps = 31, - colors = SliderDefaults.colors( - thumbColor = colorScheme.primary, - activeTrackColor = colorScheme.primary, - inactiveTrackColor = colorScheme.surfaceVariant + }, + modifier = Modifier.weight(1f) + ) + ThemeChip( + label = stringResource(R.string.clock_24_hour), + selected = + timeFormat == com.bitchat.android.ui.theme + .TimeFormatPreference.TwentyFourHour, + onClick = { + com.bitchat.android.ui.theme.TimeFormatPreferenceManager + .set( + context, + com.bitchat.android.ui.theme + .TimeFormatPreference.TwentyFourHour ) - ) - - AnimatedCountLabel( - count = powDifficulty, - text = when { - powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none) - powDifficulty <= 8 -> stringResource(R.string.about_pow_desc_very_low) - powDifficulty <= 12 -> stringResource(R.string.about_pow_desc_low) - powDifficulty <= 16 -> stringResource(R.string.about_pow_desc_medium) - powDifficulty <= 20 -> stringResource(R.string.about_pow_desc_high) - powDifficulty <= 24 -> stringResource(R.string.about_pow_desc_very_high) - else -> stringResource(R.string.about_pow_desc_extreme) - }, - fontSize = 12.sp, - fontFamily = BitchatFontFamily, - color = palette.textTertiary - ) - } - } + }, + modifier = Modifier.weight(1f) + ) } + SheetCardDivider() + SettingsToggleRow( + icon = Icons.Outlined.Schedule, + title = stringResource(R.string.clock_show_seconds), + subtitle = stringResource(R.string.clock_show_seconds_subtitle), + checked = showSeconds, + onCheckedChange = { enabled -> + com.bitchat.android.ui.theme.TimeFormatPreferenceManager + .setShowSeconds(context, enabled) + } + ) } } + } + } - // Tor Status (when enabled) - item(key = "tor_status") { - val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) } - val torProvider = remember { ArtiTorManager.getInstance() } - val torStatus by torProvider.statusFlow.collectAsState() + item(key = "language") { + val selectedLanguageName = supportedLanguages + .firstOrNull { it.languageTag == selectedLanguageTag } + ?.endonym + ?: stringResource(R.string.about_system_default) - if (torMode.value == TorMode.ON) { - Column(modifier = Modifier.padding(top = 12.dp)) { - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding), - color = colorScheme.surface, - shape = AboutCardShape - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - val statusColor = when { - torStatus.running && torStatus.bootstrapPercent >= 100 -> colorScheme.primary - torStatus.running -> palette.accentOrange - else -> colorScheme.error - } - Surface(color = statusColor, shape = CircleShape, modifier = Modifier.size(10.dp)) {} - Text( - text = if (torStatus.running) { - stringResource(R.string.about_tor_connected, torStatus.bootstrapPercent) - } else { - stringResource(R.string.about_tor_disconnected) - }, - fontFamily = BitchatFontFamily, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = colorScheme.onSurface - ) - } - if (torStatus.lastLogLine.isNotEmpty()) { - Text( - text = torStatus.lastLogLine.take(120), - fontSize = 11.sp, - fontFamily = BitchatFontFamily, - color = palette.textTertiary, - maxLines = 2 - ) - } - } - } - } - } - } - - } // end Settings tab - - // Footer - item(key = "footer") { - Column( - modifier = Modifier - .fillMaxWidth() - .padding( - start = AboutHorizontalPadding, - end = AboutHorizontalPadding, - top = 24.dp - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) + Column { + AboutSectionLabel(text = stringResource(R.string.about_language)) + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surface, + shape = AboutCardShape, ) { - if (selectedTab == AboutTab.Settings && onShowDebug != null) { - TextButton(onClick = onShowDebug) { + LanguageSettingsRow( + selectedLanguageName = selectedLanguageName, + onClick = { showLanguagePicker = true }, + ) + } + DropdownMenu( + expanded = showLanguagePicker, + onDismissRequest = { showLanguagePicker = false }, + modifier = Modifier.width(maxWidth), + ) { + LanguageMenuItem( + label = stringResource(R.string.about_system_default), + selected = selectedLanguageTag.isEmpty(), + onClick = { + showLanguagePicker = false + if (selectedLanguageTag.isNotEmpty()) { + selectedLanguageTag = "" + LanguagePreferenceManager.setLanguage("") + } + }, + ) + HorizontalDivider() + supportedLanguages.forEach { language -> + LanguageMenuItem( + label = language.endonym, + selected = selectedLanguageTag == language.languageTag, + onClick = { + showLanguagePicker = false + if (language.languageTag != selectedLanguageTag) { + selectedLanguageTag = language.languageTag + LanguagePreferenceManager.setLanguage(language.languageTag) + } + }, + ) + } + } + } + } + } + + // Settings Section - Unified Card with Toggles + item(key = "settings") { + LaunchedEffect(Unit) { PoWPreferenceManager.init(context) } + val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() + val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() + var backgroundEnabled by remember { mutableStateOf(com.bitchat.android.service.MeshServicePreferences.isBackgroundEnabled(true)) } + var liveVoiceEnabled by remember { + mutableStateOf(com.bitchat.android.features.voice.LiveVoicePreferences.isEnabled(context)) + } + val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) } + val torProvider = remember { ArtiTorManager.getInstance() } + val torStatus by torProvider.statusFlow.collectAsState() + val torAvailable = remember { torProvider.isTorAvailable() } + + Column { + AboutSectionLabel(text = stringResource(R.string.about_section_settings)) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape + ) { + Column { + // Background Mode Toggle + SettingsToggleRow( + icon = Icons.Filled.Bluetooth, + title = stringResource(R.string.about_background_title), + subtitle = stringResource(R.string.about_background_desc), + checked = backgroundEnabled, + onCheckedChange = { enabled -> + backgroundEnabled = enabled + com.bitchat.android.service.MeshServicePreferences.setBackgroundEnabled(enabled) + if (!enabled) { + com.bitchat.android.service.MeshForegroundService.stop(context) + } else { + com.bitchat.android.service.MeshForegroundService.start(context) + } + } + ) + + HorizontalDivider( + modifier = Modifier.padding(start = 54.dp), + thickness = 1.dp, + color = colorScheme.outlineVariant + ) + + SettingsToggleRow( + icon = Icons.Filled.Mic, + title = "Live push-to-talk", + subtitle = "Play voice bursts live on the mesh; voice notes are always sent on release", + checked = liveVoiceEnabled, + onCheckedChange = { enabled -> + liveVoiceEnabled = enabled + com.bitchat.android.features.voice.LiveVoicePreferences.setEnabled(context, enabled) + } + ) + + HorizontalDivider( + modifier = Modifier.padding(start = 54.dp), + thickness = 1.dp, + color = colorScheme.outlineVariant + ) + + // Proof of Work Toggle + SettingsToggleRow( + icon = Icons.Filled.Speed, + title = stringResource(R.string.about_pow), + subtitle = stringResource(R.string.about_pow_tip), + checked = powEnabled, + onCheckedChange = { PoWPreferenceManager.setPowEnabled(it) } + ) + + HorizontalDivider( + modifier = Modifier.padding(start = 54.dp), + thickness = 1.dp, + color = colorScheme.outlineVariant + ) + + // Tor Toggle + SettingsToggleRow( + icon = Icons.Filled.Security, + title = stringResource(R.string.about_tor_title), + subtitle = stringResource(R.string.about_tor_route), + checked = torMode.value == TorMode.ON, + onCheckedChange = { enabled -> + if (torAvailable) { + torMode.value = if (enabled) TorMode.ON else TorMode.OFF + TorPreferenceManager.set(context, torMode.value) + } + }, + enabled = torAvailable, + statusIndicator = if (torMode.value == TorMode.ON) { + { + val statusColor = when { + torStatus.running && torStatus.bootstrapPercent >= 100 -> colorScheme.primary + torStatus.running -> palette.accentOrange + else -> colorScheme.error + } + Surface( + color = statusColor, + shape = CircleShape, + modifier = Modifier.size(8.dp) + ) {} + } + } else null + ) + + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + + // === Prepare App for Sharing Section === + val apkViewModel: ApkDownloadViewModel = viewModel() + val apkUiState by apkViewModel.state.collectAsStateWithLifecycle() + val apkStatus = apkUiState.apkStatus + val releaseStatus = apkUiState.releaseStatus + val downloadProgress = apkUiState.downloadProgress + val shareableApk = when (apkStatus) { + is ApkPreparationStatus.Ready -> apkStatus + is ApkPreparationStatus.Downloading -> + apkStatus.shareableFallback + else -> null + } + val availableUpdate = (releaseStatus as? ApkReleaseStatus.Known) + ?.takeIf { it.isNewerThanSharedApk } + + // Handle one-shot effects (navigation, toasts, share intents) + LaunchedEffect(Unit) { + apkViewModel.onEvent(ApkUiEvent.CheckStatus) + apkViewModel.effect.collect { effect -> + when (effect) { + is ApkUiEffect.NavigateToHotspot -> { + val intent = Intent(context, HotspotActivity::class.java) + intent.putExtra(HotspotActivity.EXTRA_APK_PATH, effect.apkPath) + context.startActivity(intent) + } + is ApkUiEffect.ShareApk -> { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/vnd.android.package-archive" + putExtra(Intent.EXTRA_STREAM, effect.apkUri) + clipData = android.content.ClipData.newRawUri("", effect.apkUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + val chooser = Intent.createChooser(intent, effect.chooserTitle).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(chooser) + } + is ApkUiEffect.ShowToast -> { + Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() + } + } + } + } + + // Prepare App for Sharing Row + Row( + modifier = Modifier + .fillMaxWidth() + // Enabled by the same mapping that decides what the tap + // does, so the row can never look tappable and do + // nothing. + .clickable( + enabled = prepareRowTapAction( + apkStatus, + releaseStatus + ) != null + ) { + apkViewModel.onEvent(ApkUiEvent.PrepareRowClicked) + } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (shareableApk != null) { + Icons.Default.Share + } else { + Icons.Default.CloudDownload + }, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + + Spacer(modifier = Modifier.width(14.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = if (shareableApk != null) { + stringResource(R.string.prepare_apk_ready_title) + } else { + stringResource(R.string.prepare_apk_title) + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + if (availableUpdate != null) { + TooltipBox( + positionProvider = TooltipDefaults + .rememberTooltipPositionProvider(), + tooltip = { + PlainTooltip { + Text( + stringResource( + R.string.prepare_apk_update_available, + availableUpdate.version + ) + ) + } + }, + state = rememberTooltipState() + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = stringResource( + R.string.prepare_apk_update_warning + ), + tint = colorScheme.tertiary, + modifier = Modifier + .padding(start = 6.dp) + .size(18.dp) + .clickable { + apkViewModel.onEvent( + ApkUiEvent.DownloadUniversalClicked + ) + } + ) + } + } + } Text( - text = stringResource(R.string.about_debug_settings), - fontSize = 13.sp, - fontFamily = BitchatFontFamily, - color = colorScheme.primary + text = when (val status = apkStatus) { + is ApkPreparationStatus.Loading -> stringResource(R.string.checking) + is ApkPreparationStatus.NotDownloaded -> + stringResource( + R.string.prepare_apk_status_not_downloaded + ) + is ApkPreparationStatus.Ready -> { + val source = when { + status.source == UniversalApkManager.ApkSource.DOWNLOADED -> + stringResource(R.string.prepare_apk_source_downloaded) + status.variant == ShareableApkVariant.ARM64 -> + stringResource(R.string.prepare_apk_source_installed_arm64) + else -> + stringResource(R.string.prepare_apk_source_installed) + } + stringResource( + R.string.prepare_apk_ready_detail, + status.version, + status.sizeMB, + source + ) + } + is ApkPreparationStatus.Downloading -> + // Only the transfer has a percentage worth + // showing; the other phases are named + // instead of pretending to be at 0%. + if (status.phase.hasMeasurableProgress) { + stringResource(R.string.prepare_apk_status_downloading, downloadProgress) + } else { + stringResource(downloadPhaseLabel(status.phase)) + } + is ApkPreparationStatus.Resumable -> + stringResource( + R.string.prepare_apk_status_resumable, + context.resolveApkFailureMessage( + status.failure + ), + status.progressPercent + ) + is ApkPreparationStatus.Error -> + context.resolveApkFailureMessage( + status.failure + ) + }, + style = MaterialTheme.typography.bodySmall, + color = when (apkStatus) { + is ApkPreparationStatus.Error -> colorScheme.error + is ApkPreparationStatus.Resumable -> colorScheme.primary + else -> colorScheme.onSurface.copy(alpha = 0.6f) + }, + lineHeight = 16.sp + ) + + // Progress lives in the column, not the trailing slot, + // which leaves that slot free for a single control. + ApkDownloadProgressBar( + status = apkStatus, + progressPercent = downloadProgress + ) + } + + // One control, one width, in every state. The progress + // readout moved into the column above, so nothing else + // competes for this slot. + when (apkStatus) { + is ApkPreparationStatus.Downloading -> + ApkPrepareRowIconButton( + icon = Icons.Default.Close, + description = stringResource( + R.string.prepare_apk_stop + ), + onClick = { + apkViewModel.onEvent( + ApkUiEvent.CancelDownload + ) + } + ) + is ApkPreparationStatus.Ready -> { + if ( + apkStatus.source == + UniversalApkManager.ApkSource.INSTALLED + ) { + ApkPrepareRowIconButton( + icon = Icons.Default.CloudDownload, + description = stringResource( + R.string.prepare_apk_get_universal + ), + onClick = { + apkViewModel.onEvent( + ApkUiEvent.DownloadUniversalClicked + ) + }, + tint = colorScheme.primary + ) + } else if ( + apkStatus.source == + UniversalApkManager.ApkSource.DOWNLOADED + ) { + ApkPrepareRowIconButton( + icon = Icons.Default.Delete, + description = stringResource( + R.string.prepare_apk_button_delete + ), + onClick = { + apkViewModel.onEvent( + ApkUiEvent.DeleteClicked + ) + }, + tint = colorScheme.error + ) + } + } + is ApkPreparationStatus.Resumable, + is ApkPreparationStatus.Error -> + ApkPrepareRowIconButton( + icon = Icons.Default.Refresh, + description = stringResource( + R.string.prepare_apk_retry + ), + onClick = { + apkViewModel.onEvent( + ApkUiEvent.PrepareRowClicked + ) + }, + tint = colorScheme.primary + ) + else -> {} + } + } + + // Prepare Dialog + if (apkUiState.showPrepareDialog) { + AlertDialog( + onDismissRequest = { apkViewModel.onEvent(ApkUiEvent.DismissPrepareDialog) }, + title = { + Text( + text = stringResource( + if (availableUpdate != null) { + R.string.prepare_apk_update_dialog_title + } else { + R.string.prepare_apk_dialog_title + } + ), + style = MaterialTheme.typography.titleLarge + ) + }, + text = { + Text( + text = if (availableUpdate != null) { + stringResource( + R.string.prepare_apk_update_dialog_message, + availableUpdate.version, + availableUpdate.sizeMB + ) + } else { + stringResource( + R.string.prepare_apk_dialog_message_unknown_size + ) + }, + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + Button(onClick = { + apkViewModel.onEvent(ApkUiEvent.ConfirmDownload) + }) { + Text(stringResource(R.string.prepare_apk_dialog_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { apkViewModel.onEvent(ApkUiEvent.DismissPrepareDialog) }) { + Text(stringResource(R.string.cancel)) + } + }, + containerColor = colorScheme.surface + ) + } + + // Delete Dialog + if (apkUiState.showDeleteDialog) { + val sizeMB = (apkStatus as? ApkPreparationStatus.Ready)?.sizeMB ?: 0 + AlertDialog( + onDismissRequest = { apkViewModel.onEvent(ApkUiEvent.DismissDeleteDialog) }, + title = { + Text( + text = stringResource(R.string.prepare_apk_delete_confirm), + style = MaterialTheme.typography.titleLarge + ) + }, + text = { + Text( + text = stringResource(R.string.prepare_apk_delete_message, sizeMB), + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + Button( + onClick = { + apkViewModel.onEvent(ApkUiEvent.ConfirmDelete) + }, + colors = androidx.compose.material3.ButtonDefaults.buttonColors( + containerColor = colorScheme.error + ) + ) { + Text( + stringResource( + R.string.prepare_apk_button_delete + ) + ) + } + }, + dismissButton = { + TextButton(onClick = { apkViewModel.onEvent(ApkUiEvent.DismissDeleteDialog) }) { + Text(stringResource(R.string.cancel)) + } + }, + containerColor = colorScheme.surface + ) + } + + // A GitHub update is optional. Keep sharing visible while the + // replacement downloads or while metadata refreshes. + val canShareAPK = shareableApk != null + + AnimatedVisibility( + visible = canShareAPK, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Column { + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + + // === Share via Hotspot Row === + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + apkViewModel.onEvent(ApkUiEvent.HotspotShareClicked) + } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Wifi, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + + Spacer(modifier = Modifier.width(14.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.hotspot_share_via), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + Text( + text = stringResource(R.string.hotspot_share_via_subtitle), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.6f), + lineHeight = 16.sp + ) + } + + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = colorScheme.onSurface.copy(alpha = 0.4f), + modifier = Modifier.size(20.dp) + ) + } + + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + + // === Share via Bluetooth/Email Row (Fallback) === + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { apkViewModel.onEvent(ApkUiEvent.AppShareClicked) } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Bluetooth, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + + Spacer(modifier = Modifier.width(14.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.hotspot_share_other), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + Text( + text = stringResource(R.string.hotspot_share_other_subtitle), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.6f), + lineHeight = 16.sp + ) + } + + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = colorScheme.onSurface.copy(alpha = 0.4f), + modifier = Modifier.size(20.dp) + ) + } + + // APK Share Dialog + ApkShareExplanationDialog( + show = apkUiState.showShareApkDialog, + onConfirm = { + apkViewModel.onEvent(ApkUiEvent.ConfirmAppShare) + }, + onDismiss = { apkViewModel.onEvent(ApkUiEvent.DismissShareDialog) } ) } } - Text( - text = stringResource(R.string.about_footer), - fontSize = 11.sp, - fontFamily = BitchatFontFamily, - color = palette.textTertiary + + } + } + + // Tor unavailable hint + if (!torAvailable) { + Text( + text = stringResource(R.string.tor_not_available_in_this_build), + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + color = palette.textTertiary, + modifier = Modifier.padding( + start = AboutHorizontalPadding + 16.dp, + top = 8.dp ) - Spacer(modifier = Modifier.height(20.dp)) + ) + } + } + } + + // PoW Difficulty Slider (when enabled) + item(key = "pow_slider") { + val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() + val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() + + if (powEnabled) { + Column(modifier = Modifier.padding(top = 12.dp)) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.about_difficulty), + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + AnimatedCountLabel( + count = powDifficulty, + text = stringResource( + R.string.about_difficulty_value, + powDifficulty, + NostrProofOfWork.estimateMiningTime(powDifficulty) + ), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = colorScheme.onSurfaceVariant + ) + } + + Slider( + value = powDifficulty.toFloat(), + onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) }, + valueRange = 0f..32f, + steps = 31, + colors = SliderDefaults.colors( + thumbColor = colorScheme.primary, + activeTrackColor = colorScheme.primary, + inactiveTrackColor = colorScheme.surfaceVariant + ) + ) + + AnimatedCountLabel( + count = powDifficulty, + text = when { + powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none) + powDifficulty <= 8 -> stringResource(R.string.about_pow_desc_very_low) + powDifficulty <= 12 -> stringResource(R.string.about_pow_desc_low) + powDifficulty <= 16 -> stringResource(R.string.about_pow_desc_medium) + powDifficulty <= 20 -> stringResource(R.string.about_pow_desc_high) + powDifficulty <= 24 -> stringResource(R.string.about_pow_desc_very_high) + else -> stringResource(R.string.about_pow_desc_extreme) + }, + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + color = palette.textTertiary + ) + } } } } + } - // TopBar - Box( + // Tor Status (when enabled) + item(key = "tor_status") { + val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) } + val torProvider = remember { ArtiTorManager.getInstance() } + val torStatus by torProvider.statusFlow.collectAsState() + + if (torMode.value == TorMode.ON) { + Column(modifier = Modifier.padding(top = 12.dp)) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val statusColor = when { + torStatus.running && torStatus.bootstrapPercent >= 100 -> colorScheme.primary + torStatus.running -> palette.accentOrange + else -> colorScheme.error + } + Surface(color = statusColor, shape = CircleShape, modifier = Modifier.size(10.dp)) {} + Text( + text = if (torStatus.running) { + stringResource(R.string.about_tor_connected, torStatus.bootstrapPercent) + } else { + stringResource(R.string.about_tor_disconnected) + }, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + } + if (torStatus.lastLogLine.isNotEmpty()) { + Text( + text = torStatus.lastLogLine.take(120), + fontSize = 11.sp, + fontFamily = BitchatFontFamily, + color = palette.textTertiary, + maxLines = 2 + ) + } + } + } + } + } + } + + } // end Settings tab + + // Footer + item(key = "footer") { + Column( modifier = Modifier - .align(Alignment.TopCenter) .fillMaxWidth() - .height(64.dp) - .background(colorScheme.background.copy(alpha = topBarAlpha)) + .padding( + start = AboutHorizontalPadding, + end = AboutHorizontalPadding, + top = 24.dp + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - val dismiss = LocalSheetDismiss.current - CloseButton( - onClick = { dismiss?.invoke() ?: onDismiss() }, - modifier = modifier - .align(Alignment.CenterEnd) - .padding(horizontal = 16.dp), + if (selectedTab == AboutTab.Settings && onShowDebug != null) { + TextButton(onClick = onShowDebug) { + Text( + text = stringResource(R.string.about_debug_settings), + fontSize = 13.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.primary + ) + } + } + Text( + text = stringResource(R.string.about_footer), + fontSize = 11.sp, + fontFamily = BitchatFontFamily, + color = palette.textTertiary ) + Spacer(modifier = Modifier.height(20.dp)) } } } + + // TopBar + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(64.dp) + .background(colorScheme.background.copy(alpha = topBarAlpha)) + ) { + val dismiss = LocalSheetDismiss.current + if (dismiss != null || onClose != null) { + CloseButton( + onClick = { dismiss?.invoke() ?: onClose?.invoke() }, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(horizontal = 16.dp), + ) + } + } + } +} + +/** [AboutContent] in a bottom sheet - still how the brand glyph in the chat header opens it. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AboutSheet( + isPresented: Boolean, + onDismiss: () -> Unit, + onShowDebug: (() -> Unit)? = null, + modifier: Modifier = Modifier +) { + if (!isPresented) return + BitchatBottomSheet( + modifier = modifier, + onDismissRequest = onDismiss, + ) { + AboutContent(onShowDebug = onShowDebug, onClose = onDismiss) } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 1ae1c47f..339dc4c2 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -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() - } - ) - } -} + }} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index f4504cc2..196921a5 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -123,11 +123,8 @@ class ChatState( private val _showAppInfo = MutableStateFlow(false) val showAppInfo: StateFlow = _showAppInfo.asStateFlow() - private val _showMeshPeerList = MutableStateFlow(false) - val showMeshPeerList: StateFlow = _showMeshPeerList.asStateFlow() - - private val _privateChatSheetPeer = MutableStateFlow(null) - val privateChatSheetPeer: StateFlow = _privateChatSheetPeer.asStateFlow() + private val _openPrivateChatPeer = MutableStateFlow(null) + val openPrivateChatPeer: StateFlow = _openPrivateChatPeer.asStateFlow() private val _showVerificationSheet = MutableStateFlow(false) val showVerificationSheet: StateFlow = _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 } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index 14c7879d..4f9e76d9 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt index f0647e6c..7f6e2634 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt @@ -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() diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index f40549b6..8906c3e0 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -399,8 +399,7 @@ class ChatViewModel( val peerRSSI: StateFlow> = state.peerRSSI val peerDirect: StateFlow> = state.peerDirect val showAppInfo: StateFlow = state.showAppInfo - val showMeshPeerList: StateFlow = state.showMeshPeerList - val privateChatSheetPeer: StateFlow = state.privateChatSheetPeer + val openPrivateChatPeer: StateFlow = state.openPrivateChatPeer val showVerificationSheet: StateFlow = state.showVerificationSheet val showSecurityVerificationSheet: StateFlow = state.showSecurityVerificationSheet val legacyPrivateMediaConsent: StateFlow = @@ -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 } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 098f190b..c138b9de 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -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) diff --git a/app/src/main/java/com/bitchat/android/ui/MainScaffold.kt b/app/src/main/java/com/bitchat/android/ui/MainScaffold.kt new file mode 100644 index 00000000..9667409c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MainScaffold.kt @@ -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 + ) + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index c6f7eb49..142ccb57 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -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) } } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index c6233e1c..26ebc250 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -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(null) + val wifiAwareIdentityIDs = remember(wifiAwareConnected) { + wifiAwareConnected.keys.mapTo(mutableSetOf()) { it.lowercase() } } + + var pendingConversationDelete by remember { mutableStateOf(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) } } - } - } } } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 8eb76c44..7aba917d 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/ui/OrientationAwareActivity.kt b/app/src/main/java/com/bitchat/android/ui/OrientationAwareActivity.kt index a3914c90..a415edc5 100644 --- a/app/src/main/java/com/bitchat/android/ui/OrientationAwareActivity.kt +++ b/app/src/main/java/com/bitchat/android/ui/OrientationAwareActivity.kt @@ -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" + } } diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt index 71e59f0d..4b12fc3c 100644 --- a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -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) ) } diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt index a84c3c94..4f14988b 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -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(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 { diff --git a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt index 8dfb87b0..86ee1ed3 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt @@ -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, diff --git a/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt index 10ab9e9e..3a3fe35b 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt index f2c4b0b7..e8798a89 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt @@ -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 ) diff --git a/app/src/main/java/com/bitchat/android/ui/theme/TimeFormat.kt b/app/src/main/java/com/bitchat/android/ui/theme/TimeFormat.kt new file mode 100644 index 00000000..c0604e69 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/TimeFormat.kt @@ -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 = _formatFlow + + private val _showSecondsFlow = MutableStateFlow(false) + val showSecondsFlow: StateFlow = _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 + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a46d596f..51000368 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,6 +23,11 @@ No one connected Triple tap to clear all data Network + + + Chats + Public + now Battery Optimization Detected @@ -171,8 +176,15 @@ About Theme + Clock Settings + + 12-hour + 24-hour + Show seconds + Add seconds to message timestamps, e.g. 13:45:40 + No Tracking No servers, accounts, or data collection. @@ -475,6 +487,9 @@ Recent scan results Verify + Verifies someone already on your mesh. Both phones need Bluetooth on and to be in range — scanning does not connect you. + Not a valid bitchat code, or it expired. Codes are good for 5 minutes — ask them to reopen theirs. + That person is not connected to you yet. Wait for them to appear under People, then scan again. Scan to verify me Scan someone else\'s QR Scan someone else\'s QR diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt index 44377856..261f4a8d 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -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