From e1d07f99f546590fcf248b51040d9168ce0c6f2b Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:56:59 +0200 Subject: [PATCH] first pass --- .../ui/component/button/BitChatBrandButton.kt | 4 +- .../com/bitchat/android/ui/AboutSections.kt | 622 ++++++++++++++++++ .../java/com/bitchat/android/ui/AboutSheet.kt | 380 +++++------ .../java/com/bitchat/android/ui/ChatHeader.kt | 384 ++++++----- .../java/com/bitchat/android/ui/ChatScreen.kt | 96 +-- .../com/bitchat/android/ui/ChatUIUtils.kt | 482 ++++++-------- .../bitchat/android/ui/GeohashPeopleList.kt | 135 ++-- .../com/bitchat/android/ui/InputComponents.kt | 395 ++++++----- .../android/ui/LocationChannelsSheet.kt | 330 ++++++---- .../bitchat/android/ui/LocationNotesButton.kt | 7 +- .../android/ui/MatrixEncryptionAnimation.kt | 4 + .../bitchat/android/ui/MeshPeerListSheet.kt | 124 ++-- .../bitchat/android/ui/MessageComponents.kt | 139 ++-- .../com/bitchat/android/ui/MessageGrouping.kt | 79 +++ .../android/ui/VoiceInputComponents.kt | 2 +- .../android/ui/media/AudioMessageItem.kt | 12 +- .../android/ui/media/ImageMessageItem.kt | 12 +- .../android/ui/media/ImagePickerButton.kt | 9 +- .../android/ui/theme/BitchatPalette.kt | 106 +++ .../com/bitchat/android/ui/theme/Theme.kt | 42 +- .../bitchat/android/ui/theme/Typography.kt | 41 +- app/src/main/res/values-ar/strings.xml | 1 - app/src/main/res/values-bn/strings.xml | 1 - app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values-es/strings.xml | 1 - app/src/main/res/values-fa/strings.xml | 1 - app/src/main/res/values-fil/strings.xml | 1 - app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-hi/strings.xml | 1 - app/src/main/res/values-id/strings.xml | 1 - app/src/main/res/values-it/strings.xml | 1 - app/src/main/res/values-ja/strings.xml | 1 - app/src/main/res/values-ka/strings.xml | 1 - app/src/main/res/values-ko/strings.xml | 1 - app/src/main/res/values-mg/strings.xml | 1 - app/src/main/res/values-ne/strings.xml | 1 - app/src/main/res/values-nl/strings.xml | 1 - app/src/main/res/values-pa-rPK/strings.xml | 1 - app/src/main/res/values-pt-rBR/strings.xml | 1 - app/src/main/res/values-pt/strings.xml | 1 - app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values-sv/strings.xml | 1 - app/src/main/res/values-th/strings.xml | 1 - app/src/main/res/values-tr/strings.xml | 1 - app/src/main/res/values-ur/strings.xml | 1 - app/src/main/res/values-vi/strings.xml | 1 - app/src/main/res/values-zh/strings.xml | 1 - app/src/main/res/values/strings.xml | 64 +- .../com/bitchat/android/ui/ChatUIUtilsTest.kt | 303 ++++++++- 49 files changed, 2547 insertions(+), 1251 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/AboutSections.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt index 94529d77..15b2b40e 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt @@ -63,7 +63,9 @@ fun BitChatBrandButton( imageVector = BitChatIcon, contentDescription = contentDescription, tint = tint, - modifier = Modifier.size(16.dp), + // 20.dp rather than 16.dp: the pixel-art bubble has heavy internal negative space, + // so it reads noticeably smaller than a solid glyph at the same nominal size. + modifier = Modifier.size(20.dp), ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt new file mode 100644 index 00000000..73403b6d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -0,0 +1,622 @@ +package com.bitchat.android.ui + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AlternateEmail +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Shuffle +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.WifiOff +import androidx.compose.material.icons.outlined.BookmarkBorder +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Public +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bitchat.android.R +import com.bitchat.android.core.ui.icon.BitChatIcon +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette + +/** + * Building blocks for the redesigned About sheet. + * + * Kept in a separate file from [AboutSheet] because the sheet itself is mostly wiring for + * preferences, whereas these are pure presentation. + */ + +/** Horizontal inset shared by every About section, so cards and labels align to one grid. */ +internal val AboutHorizontalPadding = 20.dp + +/** Card corner radius for grouped rows. */ +internal val AboutCardShape = RoundedCornerShape(16.dp) + +/** Two top-level views of the sheet. */ +enum class AboutTab { + HowToUse, + BasicInfo, +} + +/** + * Small uppercase section label, e.g. `SETTINGS`. + * + * Uppercasing happens here rather than in the string resource so translators supply natural + * sentence case and locales without a case distinction are unaffected. + */ +@Composable +internal fun AboutSectionLabel( + text: String, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + Text( + text = text.uppercase(), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.8.sp, + color = palette.textTertiary, + modifier = modifier.padding(start = AboutHorizontalPadding, top = 24.dp, bottom = 8.dp) + ) +} + +/** + * Centered app identity block: logo, wordmark, tagline, version. + */ +@Composable +internal fun AboutHero( + versionName: String, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = BitChatIcon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(64.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.app_name), + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = 40.sp, + // Monospace at display size leaves too much air between glyphs; pull it in slightly + // so the wordmark reads as a single unit. + letterSpacing = (-0.5).sp, + color = colorScheme.primary + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringResource(R.string.about_tagline), + fontFamily = FontFamily.Monospace, + fontSize = 16.sp, + color = palette.textSecondary + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = stringResource(R.string.version_prefix, versionName), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = palette.textTertiary + ) + } +} + +/** + * Two-up tab bar with a sliding underline indicator. + * + * The indicator animates its offset rather than cross-fading two static bars, which is what + * makes the switch feel physically connected to the tap. + */ +@Composable +internal fun AboutTabBar( + selected: AboutTab, + onSelect: (AboutTab) -> Unit, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + val density = LocalDensity.current + + var rowWidth by remember { mutableStateOf(0.dp) } + val tabWidth = rowWidth / 2 + val indicatorOffset by animateDpAsState( + targetValue = if (selected == AboutTab.HowToUse) 0.dp else tabWidth, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "aboutTabIndicator" + ) + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .onSizeChanged { size -> + rowWidth = with(density) { size.width.toDp() } + } + ) { + Row(modifier = Modifier.fillMaxWidth()) { + AboutTabLabel( + text = stringResource(R.string.about_tab_how_to_use), + isSelected = selected == AboutTab.HowToUse, + onClick = { onSelect(AboutTab.HowToUse) }, + modifier = Modifier.weight(1f) + ) + AboutTabLabel( + text = stringResource(R.string.about_tab_basic_info), + isSelected = selected == AboutTab.BasicInfo, + onClick = { onSelect(AboutTab.BasicInfo) }, + modifier = Modifier.weight(1f) + ) + } + + Box(modifier = Modifier.fillMaxWidth()) { + HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + Box( + modifier = Modifier + .offset(x = indicatorOffset) + .width(tabWidth) + .height(2.dp) + .background(colorScheme.primary) + ) + } + } +} + +@Composable +private fun AboutTabLabel( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + + val color by animateColorAsState( + targetValue = if (isSelected) colorScheme.primary else palette.textSecondary, + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), + label = "aboutTabLabelColor" + ) + + Box( + modifier = modifier + .height(44.dp) + .clickable(onClickLabel = text) { onClick() }, + contentAlignment = Alignment.Center + ) { + Text( + text = text.uppercase(), + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.8.sp, + color = color + ) + } +} + +/** One line of the "How To Use" list: an icon plus a single instruction. */ +@Composable +private fun AboutInstructionRow( + icon: ImageVector, + text: String +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 2.dp) + .size(18.dp) + ) + Text( + text = text, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + lineHeight = 20.sp, + color = palette.textPrimary + ) + } +} + +/** + * The "How To Use" tab: a short, scannable list of the gestures that are not self-evident. + */ +@Composable +internal fun AboutHowToUseSection(modifier: Modifier = Modifier) { + val colorScheme = MaterialTheme.colorScheme + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.about_how_to_use_heading), + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = colorScheme.primary, + modifier = Modifier.padding( + start = AboutHorizontalPadding, + top = 20.dp, + bottom = 8.dp + ) + ) + + AboutInstructionRow( + icon = Icons.Filled.AlternateEmail, + text = stringResource(R.string.about_howto_nickname) + ) + AboutInstructionRow( + icon = Icons.Outlined.Public, + text = stringResource(R.string.about_howto_channels) + ) + AboutInstructionRow( + icon = Icons.Filled.Groups, + text = stringResource(R.string.about_howto_people) + ) + AboutInstructionRow( + icon = Icons.Outlined.BookmarkBorder, + text = stringResource(R.string.about_howto_bookmark) + ) + AboutInstructionRow( + icon = Icons.Filled.AlternateEmail, + text = stringResource(R.string.about_howto_mention) + ) + AboutInstructionRow( + icon = Icons.Filled.Terminal, + text = stringResource(R.string.about_howto_commands) + ) + AboutInstructionRow( + icon = Icons.Filled.DeleteForever, + text = stringResource(R.string.about_howto_panic) + ) + } +} + +/** + * The six-item capability list from the design. + * + * Icons are Material approximations of the designer's custom line art; swap in the exported + * SVGs when they are available. + */ +@Composable +internal fun AboutFeatureCard(modifier: Modifier = Modifier) { + val palette = LocalBitchatPalette.current + + Surface( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = palette.surface, + shape = AboutCardShape + ) { + Column { + val features = listOf( + Triple( + Icons.Filled.WifiOff, + R.string.about_offline_comm_title, + R.string.about_offline_comm_desc + ), + Triple( + Icons.Outlined.Lock, + R.string.about_e2e_title, + R.string.about_e2e_desc + ), + Triple( + Icons.Outlined.Public, + R.string.about_local_channels_title, + R.string.about_local_channels_desc + ), + Triple( + Icons.Outlined.VisibilityOff, + R.string.about_no_tracking_title, + R.string.about_no_tracking_desc + ), + Triple( + Icons.Filled.Shuffle, + R.string.about_ephemeral_title, + R.string.about_ephemeral_desc + ), + Triple( + Icons.Filled.DeleteForever, + R.string.about_panic_title, + R.string.about_panic_desc + ), + ) + + features.forEachIndexed { index, (icon, titleRes, descRes) -> + if (index > 0) { + HorizontalDivider( + // Inset to align with the text column, not the icon. + modifier = Modifier.padding(start = 54.dp), + thickness = 1.dp, + color = palette.outlineVariant + ) + } + AboutFeatureRow( + icon = icon, + title = stringResource(titleRes), + subtitle = stringResource(descRes) + ) + } + } + } +} + +@Composable +private fun AboutFeatureRow( + icon: ImageVector, + title: String, + subtitle: String +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 13.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 1.dp) + .size(22.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = palette.textPrimary + ) + Text( + text = subtitle, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + lineHeight = 17.sp, + color = palette.textSecondary + ) + } + } +} + +/** + * Security-audit warning banner. + * + * Deliberately styled as a tinted, outlined card rather than plain red text: it needs to be + * impossible to skim past, but it is also permanent until the audit lands, so a full-bleed + * alarm would quickly become wallpaper. + */ +@Composable +internal fun AboutWarningCard(modifier: Modifier = Modifier) { + val palette = LocalBitchatPalette.current + + Surface( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = palette.accentRed.copy(alpha = 0.12f), + border = BorderStroke(1.dp, palette.accentRed.copy(alpha = 0.35f)), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = palette.accentRed, + modifier = Modifier.size(16.dp) + ) + Text( + text = stringResource(R.string.about_warning_title).uppercase(), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + color = palette.accentRed + ) + } + Text( + text = stringResource(R.string.about_warning_body), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + lineHeight = 17.sp, + color = palette.accentRed.copy(alpha = 0.85f) + ) + } + } +} + +/** + * Small uppercase pill, e.g. the `RECOMMENDED` badge beside the Tor routing toggle. + */ +@Composable +internal fun BitchatBadge( + text: String, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + Box( + modifier = modifier + .background(palette.accentGreen.copy(alpha = 0.15f), RoundedCornerShape(4.dp)) + .padding(horizontal = 5.dp, vertical = 2.dp) + ) { + Text( + text = text.uppercase(), + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp, + color = palette.accentGreen + ) + } +} + +// MARK: - Shared bottom-sheet primitives + +/** Horizontal inset for sheet content. Cards inset from here; labels align to the same edge. */ +internal val SheetHorizontalPadding = 16.dp + +/** + * Section divider label used across the sheets, e.g. `BOOKMARKED`, `NEARBY`, `ON LOCATION`. + */ +@Composable +internal fun SheetSectionLabel( + text: String, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + Text( + text = text.uppercase(), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.8.sp, + color = palette.textTertiary, + modifier = modifier + .fillMaxWidth() + .padding( + start = SheetHorizontalPadding + 14.dp, + end = SheetHorizontalPadding, + top = 20.dp, + bottom = 6.dp + ) + ) +} + +/** + * Circular tinted badge holding a section's icon, used at the top of the sheets. + */ +@Composable +internal fun SheetHeaderBadge( + icon: ImageVector, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + Box( + modifier = modifier + .size(44.dp) + .background(palette.surface, androidx.compose.foundation.shape.CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } +} + +/** + * Full-width destructive action, e.g. `REMOVE LOCATION ACCESS`. + * + * Tinted fill plus an outline rather than a solid red button: the action is legitimate but + * rarely wanted, and a solid red block would dominate the sheet. + */ +@Composable +internal fun SheetDestructiveButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isDestructive: Boolean = true +) { + val palette = LocalBitchatPalette.current + val accent = if (isDestructive) palette.accentRed else palette.accentGreen + + Surface( + onClick = onClick, + modifier = modifier + .fillMaxWidth() + .height(44.dp), + shape = RoundedCornerShape(10.dp), + color = accent.copy(alpha = 0.10f), + border = BorderStroke(1.dp, accent.copy(alpha = 0.30f)) + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = text.uppercase(), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.8.sp, + color = accent + ) + } + } +} + 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 f137ac63..ce170343 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -1,6 +1,9 @@ package com.bitchat.android.ui +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -35,6 +38,8 @@ import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.net.TorMode import com.bitchat.android.net.TorPreferenceManager import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette /** * Feature row for displaying app capabilities @@ -46,11 +51,12 @@ private fun FeatureRow( subtitle: String ) { val colorScheme = MaterialTheme.colorScheme - + val palette = LocalBitchatPalette.current + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), + .padding(horizontal = 16.dp, vertical = 13.dp), verticalAlignment = Alignment.Top ) { Icon( @@ -58,22 +64,24 @@ private fun FeatureRow( contentDescription = null, tint = colorScheme.primary, modifier = Modifier - .padding(top = 2.dp) + .padding(top = 1.dp) .size(22.dp) ) - Spacer(modifier = Modifier.width(14.dp)) + Spacer(modifier = Modifier.width(16.dp)) Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( text = title, - style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = colorScheme.onSurface + color = palette.textPrimary ) Text( text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onSurface.copy(alpha = 0.6f), - lineHeight = 18.sp + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = palette.textSecondary, + lineHeight = 17.sp ) } } @@ -89,18 +97,26 @@ private fun ThemeChip( onClick: () -> Unit, modifier: Modifier = Modifier ) { - val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - + val palette = LocalBitchatPalette.current + + // Cross-fade the chip so switching theme does not read as two separate flashes (the chip + // recolouring plus the whole app recolouring underneath it). + val containerColor by animateColorAsState( + targetValue = if (selected) palette.accentGreen else palette.surfaceVariant, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "themeChipContainer" + ) + val labelColor by animateColorAsState( + targetValue = if (selected) Color.White else palette.textSecondary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "themeChipLabel" + ) + Surface( modifier = modifier, onClick = onClick, shape = RoundedCornerShape(10.dp), - color = if (selected) { - if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) - } else { - colorScheme.surfaceVariant.copy(alpha = 0.5f) - } + color = containerColor ) { Box( modifier = Modifier @@ -110,9 +126,10 @@ private fun ThemeChip( ) { Text( text = label, - style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, - color = if (selected) Color.White else colorScheme.onSurface.copy(alpha = 0.8f) + color = labelColor ) } } @@ -133,23 +150,23 @@ private fun SettingsToggleRow( statusIndicator: (@Composable () -> Unit)? = null ) { val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - + val palette = LocalBitchatPalette.current + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), + .padding(horizontal = 16.dp, vertical = 13.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = icon, contentDescription = null, - tint = if (enabled) colorScheme.primary else colorScheme.onSurface.copy(alpha = 0.3f), + tint = if (enabled) colorScheme.primary else palette.textTertiary, modifier = Modifier.size(22.dp) ) - - Spacer(modifier = Modifier.width(14.dp)) - + + Spacer(modifier = Modifier.width(16.dp)) + Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp) @@ -160,31 +177,33 @@ private fun SettingsToggleRow( ) { Text( text = title, - style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = if (enabled) colorScheme.onSurface else colorScheme.onSurface.copy(alpha = 0.4f) + color = if (enabled) palette.textPrimary else palette.textTertiary ) statusIndicator?.invoke() } Text( text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onSurface.copy(alpha = if (enabled) 0.6f else 0.3f), - lineHeight = 16.sp + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = if (enabled) palette.textSecondary else palette.textTertiary, + lineHeight = 17.sp ) } - + Spacer(modifier = Modifier.width(16.dp)) - + Switch( checked = checked, onCheckedChange = { if (enabled) onCheckedChange(it) }, enabled = enabled, colors = SwitchDefaults.colors( checkedThumbColor = Color.White, - checkedTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), + checkedTrackColor = palette.accentGreen, uncheckedThumbColor = Color.White, - uncheckedTrackColor = colorScheme.surfaceVariant + uncheckedTrackColor = palette.surfaceVariant ) ) } @@ -221,12 +240,15 @@ fun AboutSheet( } val topBarAlpha by animateFloatAsState( targetValue = if (isScrolled) 0.98f else 0f, + animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing), label = "topBarAlpha" ) val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - + val palette = LocalBitchatPalette.current + val isDark = palette.isDark + var selectedTab by remember { mutableStateOf(AboutTab.HowToUse) } + if (isPresented) { BitchatBottomSheet( modifier = modifier, @@ -236,103 +258,48 @@ fun AboutSheet( LazyColumn( state = lazyListState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 80.dp, bottom = 32.dp), - verticalArrangement = Arrangement.spacedBy(20.dp) + contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) ) { // Header Section - App Identity - item(key = "header") { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = stringResource(R.string.app_name), - style = TextStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = 28.sp, - letterSpacing = 1.sp - ), - color = colorScheme.onBackground - ) - Text( - text = stringResource(R.string.version_prefix, versionName ?: ""), - fontSize = 13.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onBackground.copy(alpha = 0.5f) - ) - Text( - text = stringResource(R.string.about_tagline), - fontSize = 13.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onBackground.copy(alpha = 0.6f), - modifier = Modifier.padding(top = 4.dp) - ) + item(key = "hero") { + AboutHero(versionName = versionName ?: "") + } + + item(key = "tabs") { + AboutTabBar( + selected = selectedTab, + onSelect = { selectedTab = it }, + modifier = Modifier.padding(top = 24.dp) + ) + } + + if (selectedTab == AboutTab.HowToUse) { + item(key = "how_to_use") { + AboutHowToUseSection() } } + if (selectedTab == AboutTab.BasicInfo) { // Features Section - Grouped Card item(key = "features") { - Column(modifier = Modifier.padding(horizontal = 20.dp)) { - Text( - text = stringResource(R.string.about_appearance).uppercase(), - style = MaterialTheme.typography.labelSmall, - color = colorScheme.onBackground.copy(alpha = 0.5f), - letterSpacing = 0.5.sp, - modifier = Modifier.padding(start = 16.dp, bottom = 8.dp) - ) - Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) - ) { - Column { - FeatureRow( - icon = Icons.Filled.Bluetooth, - title = stringResource(R.string.about_offline_mesh_title), - subtitle = stringResource(R.string.about_offline_mesh_desc) - ) - HorizontalDivider( - modifier = Modifier.padding(start = 56.dp), - color = colorScheme.outline.copy(alpha = 0.12f) - ) - FeatureRow( - icon = Icons.Default.Public, - title = stringResource(R.string.about_online_geohash_title), - subtitle = stringResource(R.string.about_online_geohash_desc) - ) - HorizontalDivider( - modifier = Modifier.padding(start = 56.dp), - color = colorScheme.outline.copy(alpha = 0.12f) - ) - FeatureRow( - icon = Icons.Default.Lock, - title = stringResource(R.string.about_e2e_title), - subtitle = stringResource(R.string.about_e2e_desc) - ) - } - } + Column { + AboutSectionLabel(text = stringResource(R.string.about_section_about)) + AboutFeatureCard() } } // Appearance Section item(key = "appearance") { - Column(modifier = Modifier.padding(horizontal = 20.dp)) { - Text( - text = "THEME", - style = MaterialTheme.typography.labelSmall, - color = colorScheme.onBackground.copy(alpha = 0.5f), - letterSpacing = 0.5.sp, - modifier = Modifier.padding(start = 16.dp, bottom = 8.dp) - ) + Column { + AboutSectionLabel(text = stringResource(R.string.about_section_theme)) val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState() Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = palette.surface, + shape = AboutCardShape ) { Row( modifier = Modifier @@ -374,18 +341,14 @@ fun AboutSheet( val torStatus by torProvider.statusFlow.collectAsState() val torAvailable = remember { torProvider.isTorAvailable() } - Column(modifier = Modifier.padding(horizontal = 20.dp)) { - Text( - text = "SETTINGS", - style = MaterialTheme.typography.labelSmall, - color = colorScheme.onBackground.copy(alpha = 0.5f), - letterSpacing = 0.5.sp, - modifier = Modifier.padding(start = 16.dp, bottom = 8.dp) - ) + Column { + AboutSectionLabel(text = stringResource(R.string.about_section_settings)) Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = palette.surface, + shape = AboutCardShape ) { Column { // Background Mode Toggle @@ -404,12 +367,13 @@ fun AboutSheet( } } ) - + HorizontalDivider( - modifier = Modifier.padding(start = 56.dp), - color = colorScheme.outline.copy(alpha = 0.12f) + modifier = Modifier.padding(start = 54.dp), + thickness = 1.dp, + color = palette.outlineVariant ) - + // Proof of Work Toggle SettingsToggleRow( icon = Icons.Filled.Speed, @@ -418,16 +382,17 @@ fun AboutSheet( checked = powEnabled, onCheckedChange = { PoWPreferenceManager.setPowEnabled(it) } ) - + HorizontalDivider( - modifier = Modifier.padding(start = 56.dp), - color = colorScheme.outline.copy(alpha = 0.12f) + modifier = Modifier.padding(start = 54.dp), + thickness = 1.dp, + color = palette.outlineVariant ) - + // Tor Toggle SettingsToggleRow( icon = Icons.Filled.Security, - title = "Tor Network", + title = stringResource(R.string.about_tor_title), subtitle = stringResource(R.string.about_tor_route), checked = torMode.value == TorMode.ON, onCheckedChange = { enabled -> @@ -440,9 +405,9 @@ fun AboutSheet( statusIndicator = if (torMode.value == TorMode.ON) { { val statusColor = when { - torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) - torStatus.running -> Color(0xFFFF9500) - else -> Color(0xFFFF3B30) + torStatus.running && torStatus.bootstrapPercent >= 100 -> palette.accentGreen + torStatus.running -> palette.accentOrange + else -> palette.accentRed } Surface( color = statusColor, @@ -454,15 +419,18 @@ fun AboutSheet( ) } } - + // Tor unavailable hint if (!torAvailable) { Text( text = stringResource(R.string.tor_not_available_in_this_build), fontSize = 12.sp, fontFamily = FontFamily.Monospace, - color = colorScheme.onBackground.copy(alpha = 0.5f), - modifier = Modifier.padding(start = 16.dp, top = 8.dp) + color = palette.textTertiary, + modifier = Modifier.padding( + start = AboutHorizontalPadding + 16.dp, + top = 8.dp + ) ) } } @@ -472,13 +440,15 @@ fun AboutSheet( item(key = "pow_slider") { val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() - + if (powEnabled) { - Column(modifier = Modifier.padding(horizontal = 20.dp)) { + Column(modifier = Modifier.padding(top = 12.dp)) { Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = palette.surface, + shape = AboutCardShape ) { Column( modifier = Modifier.padding(16.dp), @@ -490,30 +460,36 @@ fun AboutSheet( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Difficulty", - style = MaterialTheme.typography.bodyMedium, + text = stringResource(R.string.about_difficulty), + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = colorScheme.onSurface + color = palette.textPrimary ) Text( - text = "$powDifficulty bits • ${NostrProofOfWork.estimateMiningTime(powDifficulty)}", - style = MaterialTheme.typography.bodySmall, + text = stringResource( + R.string.about_difficulty_value, + powDifficulty, + NostrProofOfWork.estimateMiningTime(powDifficulty) + ), fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.6f) + fontSize = 12.sp, + color = palette.textSecondary ) } - + Slider( value = powDifficulty.toFloat(), onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) }, valueRange = 0f..32f, steps = 31, colors = SliderDefaults.colors( - thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), - activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + thumbColor = palette.accentGreen, + activeTrackColor = palette.accentGreen, + inactiveTrackColor = palette.surfaceVariant ) ) - + Text( text = when { powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none) @@ -526,7 +502,7 @@ fun AboutSheet( }, fontSize = 12.sp, fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.5f) + color = palette.textTertiary ) } } @@ -539,13 +515,15 @@ fun AboutSheet( 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(horizontal = 20.dp)) { + Column(modifier = Modifier.padding(top = 12.dp)) { Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = palette.surface, + shape = AboutCardShape ) { Column( modifier = Modifier.padding(16.dp), @@ -556,16 +534,21 @@ fun AboutSheet( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { val statusColor = when { - torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) - torStatus.running -> Color(0xFFFF9500) - else -> Color(0xFFFF3B30) + torStatus.running && torStatus.bootstrapPercent >= 100 -> palette.accentGreen + torStatus.running -> palette.accentOrange + else -> palette.accentRed } Surface(color = statusColor, shape = CircleShape, modifier = Modifier.size(10.dp)) {} Text( - text = if (torStatus.running) "Connected (${torStatus.bootstrapPercent}%)" else "Disconnected", - style = MaterialTheme.typography.bodyMedium, + text = if (torStatus.running) { + stringResource(R.string.about_tor_connected, torStatus.bootstrapPercent) + } else { + stringResource(R.string.about_tor_disconnected) + }, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = colorScheme.onSurface + color = palette.textPrimary ) } if (torStatus.lastLogLine.isNotEmpty()) { @@ -573,7 +556,7 @@ fun AboutSheet( text = torStatus.lastLogLine.take(120), fontSize = 11.sp, fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.5f), + color = palette.textTertiary, maxLines = 2 ) } @@ -583,49 +566,22 @@ fun AboutSheet( } } - // Emergency Warning + // Security audit warning item(key = "warning") { - Surface( - modifier = Modifier - .padding(horizontal = 20.dp) - .fillMaxWidth(), - color = colorScheme.error.copy(alpha = 0.1f), - shape = RoundedCornerShape(16.dp) - ) { - Row( - modifier = Modifier.padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top - ) { - Icon( - imageVector = Icons.Filled.Warning, - contentDescription = null, - tint = colorScheme.error, - modifier = Modifier.size(20.dp) - ) - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = stringResource(R.string.about_emergency_title), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - color = colorScheme.error - ) - Text( - text = stringResource(R.string.about_emergency_tip), - fontSize = 13.sp, - color = colorScheme.onSurface.copy(alpha = 0.7f) - ) - } - } - } + AboutWarningCard(modifier = Modifier.padding(top = 24.dp)) } + } // end BasicInfo tab // Footer item(key = "footer") { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp), + .padding( + start = AboutHorizontalPadding, + end = AboutHorizontalPadding, + top = 24.dp + ), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -641,9 +597,9 @@ fun AboutSheet( } Text( text = stringResource(R.string.about_footer), - fontSize = 12.sp, + fontSize = 11.sp, fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.4f) + color = palette.textTertiary ) Spacer(modifier = Modifier.height(20.dp)) } @@ -656,7 +612,7 @@ fun AboutSheet( .align(Alignment.TopCenter) .fillMaxWidth() .height(64.dp) - .background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha)) + .background(palette.background.copy(alpha = topBarAlpha)) ) { CloseButton( onClick = onDismiss, diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index e91e30be..45657cef 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -2,20 +2,27 @@ package com.bitchat.android.ui import android.util.Log +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalFocusManager @@ -30,28 +37,71 @@ import androidx.compose.foundation.Canvas import androidx.compose.ui.geometry.Offset import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.core.ui.component.button.BitChatBrandButton +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette /** * Header components for ChatScreen * Extracted from ChatScreen.kt for better organization */ +/** Height of the chat top bar. Taller than the old 42.dp so 40.dp tap targets fit properly. */ +val ChatHeaderHeight = 48.dp +/** Standard icon size in the header. */ +private val HeaderIconSize = 18.dp +/** Minimum tap target for every interactive element in the header. */ +private val HeaderTapTarget = 40.dp + +/** Corner radius for the header's tappable label+icon clusters. */ +private val HeaderClusterShape = RoundedCornerShape(8.dp) + +/** + * A minimum-48x40 tap target wrapping a small icon. + * + * The old header used bare 16.dp icons with `Modifier.clickable`, which produced tap targets far + * below the accessibility minimum and made the channel/bookmark controls genuinely hard to hit. + */ +@Composable +private fun HeaderIconButton( + onClick: () -> Unit, + contentDescription: String?, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Box( + modifier = modifier + .size(HeaderTapTarget) + .clip(CircleShape) + .clickable(onClickLabel = contentDescription) { onClick() }, + contentAlignment = Alignment.Center + ) { + content() + } +} @Composable fun TorStatusDot( modifier: Modifier = Modifier ) { + val palette = LocalBitchatPalette.current val torProvider = remember { com.bitchat.android.net.ArtiTorManager.getInstance() } val torStatus by torProvider.statusFlow.collectAsState() if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) { - val dotColor = when { - torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) // Orange - bootstrapping - torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851) // Green - connected - else -> Color.Red // Red - error/disconnected + val targetColor = when { + torStatus.running && torStatus.bootstrapPercent < 100 -> palette.accentOrange + torStatus.running && torStatus.bootstrapPercent >= 100 -> palette.accentGreen + else -> palette.accentRed } + // Cross-fade rather than snap: Tor flips through bootstrapping states frequently and a + // hard colour cut in the corner of the screen reads as a glitch. + val dotColor by animateColorAsState( + targetValue = targetColor, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "torDotColor" + ) Canvas( modifier = modifier ) { @@ -125,7 +175,7 @@ fun NicknameEditor( Text( text = stringResource(R.string.at_symbol), style = MaterialTheme.typography.bodyMedium, - color = colorScheme.primary.copy(alpha = 0.8f) + color = colorScheme.primary.copy(alpha = 0.7f) ) BasicTextField( @@ -144,7 +194,7 @@ fun NicknameEditor( } ), modifier = Modifier - .widthIn(max = 120.dp) + .widthIn(max = 140.dp) .horizontalScroll(scrollState) ) } @@ -161,45 +211,56 @@ fun PeerCounter( onClick: () -> Unit, modifier: Modifier = Modifier ) { - val colorScheme = MaterialTheme.colorScheme - + val palette = LocalBitchatPalette.current + // Compute channel-aware people count and color (matches iOS logic exactly) val (peopleCount, countColor) = when (selectedLocationChannel) { is com.bitchat.android.geohash.ChannelID.Location -> { // Geohash channel: show geohash participants val count = geohashPeople.size - val green = Color(0xFF00C851) // Standard green - Pair(count, if (count > 0) green else Color.Gray) + Pair(count, if (count > 0) palette.accentGreen else palette.textTertiary) } is com.bitchat.android.geohash.ChannelID.Mesh, null -> { // Mesh channel: show Bluetooth-connected peers (excluding self) val count = connectedPeers.size - val meshBlue = Color(0xFF007AFF) // iOS-style blue for mesh - Pair(count, if (isConnected && count > 0) meshBlue else Color.Gray) + Pair(count, if (isConnected && count > 0) palette.accentBlue else palette.textTertiary) } } - + + // Peers come and go constantly; fading the tint avoids a flicker every time the count + // crosses zero. + val animatedCountColor by animateColorAsState( + targetValue = countColor, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "peerCountColor" + ) + Row( verticalAlignment = Alignment.CenterVertically, - modifier = modifier.clickable { onClick() }.padding(end = 8.dp) // Added right margin to match "bitchat" logo spacing + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier + .clip(HeaderClusterShape) + .clickable { onClick() } + .height(HeaderTapTarget) + .padding(horizontal = 6.dp) ) { Icon( - imageVector = Icons.Default.Group, + // `Groups` reads as a crowd at 18.dp, where `Group`'s two-person silhouette turns + // into an indistinct blob. + imageVector = Icons.Filled.Groups, contentDescription = when (selectedLocationChannel) { is com.bitchat.android.geohash.ChannelID.Location -> stringResource(R.string.cd_geohash_participants) else -> stringResource(R.string.cd_connected_peers) }, - modifier = Modifier.size(16.dp), - tint = countColor + modifier = Modifier.size(HeaderIconSize), + tint = animatedCountColor ) - Spacer(modifier = Modifier.width(4.dp)) Text( text = "$peopleCount", style = MaterialTheme.typography.bodyMedium, - color = countColor, - fontSize = 16.sp, + color = animatedCountColor, fontWeight = FontWeight.Medium ) @@ -207,8 +268,7 @@ fun PeerCounter( Text( text = stringResource(R.string.channel_count_prefix) + "${joinedChannels.size}", style = MaterialTheme.typography.bodyMedium, - color = if (isConnected) Color(0xFF00C851) else Color.Red, - fontSize = 16.sp, + color = if (isConnected) palette.accentGreen else palette.accentRed, fontWeight = FontWeight.Medium ) } @@ -266,48 +326,36 @@ private fun ChannelHeader( onSidebarClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme - + val palette = LocalBitchatPalette.current + Box(modifier = Modifier.fillMaxWidth()) { - // Back button - positioned all the way to the left with minimal margin - Button( + // Back: a chevron alone is unambiguous here and buys back ~40.dp of title space that + // the old "< back" label consumed. + HeaderIconButton( onClick = onBackClick, - colors = ButtonDefaults.buttonColors( - containerColor = Color.Transparent, - contentColor = colorScheme.primary - ), - contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), // Reduced horizontal padding - modifier = Modifier - .align(Alignment.CenterStart) - .offset(x = (-8).dp) // Move even further left to minimize margin + contentDescription = stringResource(R.string.back), + modifier = Modifier.align(Alignment.CenterStart) ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Filled.ArrowBack, - contentDescription = stringResource(R.string.back), - modifier = Modifier.size(16.dp), - tint = colorScheme.primary - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = stringResource(R.string.chat_back), - style = MaterialTheme.typography.bodyMedium, - color = colorScheme.primary - ) - } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + modifier = Modifier.size(20.dp), + tint = colorScheme.primary + ) } - + // Title - perfectly centered regardless of other elements Text( - text = stringResource(R.string.chat_channel_prefix, channel), + text = "#$channel", style = MaterialTheme.typography.titleMedium, - color = Color(0xFFFF9500), // Orange to match input field + color = colorScheme.primary, modifier = Modifier .align(Alignment.Center) + .clip(HeaderClusterShape) .clickable { onSidebarClick() } + .padding(horizontal = 8.dp, vertical = 4.dp) ) - + // Leave button - positioned on the right TextButton( onClick = onLeaveChannel, @@ -315,8 +363,8 @@ private fun ChannelHeader( ) { Text( text = stringResource(R.string.chat_leave), - style = MaterialTheme.typography.bodySmall, - color = Color.Red + style = MaterialTheme.typography.labelMedium, + color = palette.accentRed ) } } @@ -334,6 +382,7 @@ private fun MainHeader( viewModel: ChatViewModel ) { val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle() val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle() @@ -349,80 +398,49 @@ private fun MainHeader( Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier.fillMaxHeight(), - verticalAlignment = Alignment.CenterVertically - ) { - BitChatBrandButton( - onClick = onTitleClick, - onTripleClick = onTripleTitleClick, - contentDescription = stringResource(R.string.cd_open_about), - ) + // MARK: - Identity cluster + BitChatBrandButton( + onClick = onTitleClick, + onTripleClick = onTripleTitleClick, + contentDescription = stringResource(R.string.cd_open_about), + ) - Text( - text = "/", - style = MaterialTheme.typography.bodyMedium, - color = colorScheme.primary, - ) + Text( + text = "/", + style = MaterialTheme.typography.bodyMedium, + // Dimmed: the slash is a separator, not content. At full brightness it competed + // with the nickname beside it. + color = colorScheme.primary.copy(alpha = 0.45f), + modifier = Modifier.padding(horizontal = 2.dp) + ) - Spacer(modifier = Modifier.width(2.dp)) - - NicknameEditor( - value = nickname, - onValueChange = onNicknameChange - ) - } - - // Right section with location channels button and peer counter + NicknameEditor( + value = nickname, + onValueChange = onNicknameChange + ) + + Spacer(modifier = Modifier.weight(1f)) + + // MARK: - Status cluster. Order matches the design: bookmark, channel, people. Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp) + // Tight, because every child below is its own >=40.dp tap target. + horizontalArrangement = Arrangement.spacedBy(2.dp) ) { - // Unread private messages badge (click to open most recent DM) if (hasUnreadPrivateMessages.isNotEmpty()) { - // Render icon directly to avoid symbol resolution issues - Icon( - imageVector = Icons.Filled.Email, - contentDescription = stringResource(R.string.cd_unread_private_messages), - modifier = Modifier - .size(16.dp) - .clickable { viewModel.openLatestUnreadPrivateChat() }, - tint = Color(0xFFFF9500) - ) - } - - // Location channels button (matching iOS implementation) and bookmark grouped tightly - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 4.dp)) { - LocationChannelsButton( - viewModel = viewModel, - onClick = onLocationChannelsClick - ) - - // Bookmark toggle for current geohash (not shown for mesh) - val currentGeohash: String? = when (val sc = selectedLocationChannel) { - is com.bitchat.android.geohash.ChannelID.Location -> sc.channel.geohash - else -> null - } - if (currentGeohash != null) { - val isBookmarked = bookmarks.contains(currentGeohash) - Box( - modifier = Modifier - .padding(start = 2.dp) // minimal gap between geohash and bookmark - .size(20.dp) - .clickable { bookmarksStore.toggle(currentGeohash) }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, - contentDescription = stringResource(R.string.cd_toggle_bookmark), - tint = if (isBookmarked) Color(0xFF00C851) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f), - modifier = Modifier.size(16.dp) - ) - } + HeaderIconButton( + onClick = { viewModel.openLatestUnreadPrivateChat() }, + contentDescription = stringResource(R.string.cd_unread_private_messages) + ) { + Icon( + imageVector = Icons.Filled.Email, + contentDescription = stringResource(R.string.cd_unread_private_messages), + modifier = Modifier.size(HeaderIconSize), + tint = palette.accentOrange + ) } } @@ -432,19 +450,43 @@ private fun MainHeader( onClick = onLocationNotesClick ) - // Tor status dot when Tor is enabled - TorStatusDot( - modifier = Modifier - .size(8.dp) - .padding(start = 0.dp, end = 2.dp) - ) - + // Tor status dot. Wrapped in a fixed box so its optical position no longer depends + // on ad-hoc asymmetric padding. + Box(modifier = Modifier.size(16.dp), contentAlignment = Alignment.Center) { + TorStatusDot(modifier = Modifier.size(6.dp)) + } + // PoW status indicator PoWStatusIndicator( modifier = Modifier, style = PoWIndicatorStyle.COMPACT ) - Spacer(modifier = Modifier.width(2.dp)) + + // Bookmark toggle for current geohash (not shown for mesh) + val currentGeohash: String? = when (val sc = selectedLocationChannel) { + is com.bitchat.android.geohash.ChannelID.Location -> sc.channel.geohash + else -> null + } + if (currentGeohash != null) { + val isBookmarked = bookmarks.contains(currentGeohash) + HeaderIconButton( + onClick = { bookmarksStore.toggle(currentGeohash) }, + contentDescription = stringResource(R.string.cd_toggle_bookmark) + ) { + Icon( + imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, + contentDescription = stringResource(R.string.cd_toggle_bookmark), + tint = if (isBookmarked) palette.accentGreen else palette.textSecondary, + modifier = Modifier.size(HeaderIconSize) + ) + } + } + + LocationChannelsButton( + viewModel = viewModel, + onClick = onLocationChannelsClick + ) + PeerCounter( connectedPeers = connectedPeers.filter { it != viewModel.myPeerID }, joinedChannels = joinedChannels, @@ -458,56 +500,64 @@ private fun MainHeader( } } +/** + * Current channel indicator: a globe for geohash channels, a Bluetooth glyph for the local mesh. + * + * The design brief asked for the "addition of globe icon to represent channels". Previously this + * was a text-only badge wrapped in an M3 [Button], which imposed a hidden 58.dp minimum width + * and 40.dp minimum height that fought the header's explicit sizing. + */ @Composable private fun LocationChannelsButton( viewModel: ChatViewModel, onClick: () -> Unit ) { - val colorScheme = MaterialTheme.colorScheme - + val palette = LocalBitchatPalette.current + // Get current channel selection from location manager val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val teleported by viewModel.isTeleported.collectAsStateWithLifecycle() - - val (badgeText, badgeColor) = when (selectedChannel) { - is com.bitchat.android.geohash.ChannelID.Mesh -> { - "#mesh" to Color(0xFF007AFF) // iOS blue for mesh - } - is com.bitchat.android.geohash.ChannelID.Location -> { - val geohash = (selectedChannel as com.bitchat.android.geohash.ChannelID.Location).channel.geohash - "#$geohash" to Color(0xFF00C851) // Green for location - } - null -> "#mesh" to Color(0xFF007AFF) // Default to mesh + + val isLocation = selectedChannel is com.bitchat.android.geohash.ChannelID.Location + val badgeText = when (val channel = selectedChannel) { + is com.bitchat.android.geohash.ChannelID.Location -> "#${channel.channel.geohash}" + else -> "#mesh" } - - Button( - onClick = onClick, - colors = ButtonDefaults.buttonColors( - containerColor = Color.Transparent, - contentColor = badgeColor - ), - contentPadding = PaddingValues(start = 4.dp, end = 0.dp, top = 2.dp, bottom = 2.dp) + val badgeColor = if (isLocation) palette.accentGreen else palette.accentBlue + val badgeIcon = if (isLocation) Icons.Outlined.Public else Icons.Filled.Bluetooth + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .clip(HeaderClusterShape) + .clickable(onClickLabel = stringResource(R.string.location_channels_title)) { onClick() } + .height(HeaderTapTarget) + .padding(horizontal = 6.dp) ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = badgeText, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace - ), - color = badgeColor, - maxLines = 1 + Icon( + imageVector = badgeIcon, + contentDescription = null, + modifier = Modifier.size(HeaderIconSize), + tint = badgeColor + ) + + Text( + text = badgeText, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = badgeColor, + maxLines = 1 + ) + + // Teleportation indicator (like iOS) + if (teleported) { + Icon( + imageVector = Icons.Default.PinDrop, + contentDescription = stringResource(R.string.cd_teleported), + modifier = Modifier.size(12.dp), + tint = badgeColor ) - - // Teleportation indicator (like iOS) - if (teleported) { - Spacer(modifier = Modifier.width(2.dp)) - Icon( - imageVector = Icons.Default.PinDrop, - contentDescription = stringResource(R.string.cd_teleported), - modifier = Modifier.size(12.dp), - tint = badgeColor - ) - } } } } 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 78d5b571..340f180c 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -5,6 +5,8 @@ package com.bitchat.android.ui import androidx.compose.animation.* +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material3.* @@ -26,6 +28,8 @@ import androidx.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.model.BitchatMessage import com.bitchat.android.ui.media.FullScreenImageViewer +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette /** * Main ChatScreen - REFACTORED to use component-based architecture @@ -40,6 +44,7 @@ import com.bitchat.android.ui.media.FullScreenImageViewer @Composable fun ChatScreen(viewModel: ChatViewModel) { val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current val messages by viewModel.messages.collectAsStateWithLifecycle() val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() val nickname by viewModel.nickname.collectAsStateWithLifecycle() @@ -114,7 +119,7 @@ fun ChatScreen(viewModel: ChatViewModel) { .fillMaxSize() .background(colorScheme.background) // Extend background to fill entire screen including status bar ) { - val headerHeight = 42.dp + val headerHeight = ChatHeaderHeight // Main content area that responds to keyboard/window insets Column( @@ -263,14 +268,23 @@ fun ChatScreen(viewModel: ChatViewModel) { .windowInsetsPadding(WindowInsets.statusBars) .offset(y = headerHeight) .zIndex(1f), - color = colorScheme.outline.copy(alpha = 0.3f) + thickness = 1.dp, + color = palette.outlineVariant ) // Scroll-to-bottom floating button AnimatedVisibility( visible = isScrolledUp, - enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), + // Short and eased: the button appears mid-scroll, so a slow entrance draws the eye + // away from the messages the user is actually reading. + enter = slideInVertically( + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + initialOffsetY = { it / 2 } + ) + fadeIn(tween(BitchatMotion.STANDARD_MS)), + exit = slideOutVertically( + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), + targetOffsetY = { it / 2 } + ) + fadeOut(tween(BitchatMotion.QUICK_MS)), modifier = Modifier .align(Alignment.BottomEnd) .padding(end = 16.dp, bottom = 64.dp) @@ -280,16 +294,16 @@ fun ChatScreen(viewModel: ChatViewModel) { ) { Surface( shape = CircleShape, - color = colorScheme.background, + color = palette.surface, tonalElevation = 3.dp, shadowElevation = 6.dp, - border = BorderStroke(2.dp, Color(0xFF00C851)) + border = BorderStroke(1.dp, palette.accentGreen) ) { IconButton(onClick = { forceScrollToBottom = !forceScrollToBottom }) { Icon( imageVector = Icons.Filled.ArrowDownward, contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom), - tint = Color(0xFF00C851) + tint = palette.accentGreen ) } } @@ -394,12 +408,14 @@ fun ChatInputSection( colorScheme: ColorScheme, showMediaButtons: Boolean ) { + val palette = LocalBitchatPalette.current Surface( modifier = Modifier.fillMaxWidth(), color = colorScheme.background ) { + // No divider above the composer: the pill's own border provides the separation, and a + // full-width rule on top of it read as a double line. Column { - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) // Command suggestions box if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { CommandSuggestionsBox( @@ -407,7 +423,7 @@ fun ChatInputSection( onSuggestionClick = onCommandSuggestionClick, modifier = Modifier.fillMaxWidth() ) - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f)) + HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) } // Mention suggestions box if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) { @@ -416,7 +432,7 @@ fun ChatInputSection( onSuggestionClick = onMentionSuggestionClick, modifier = Modifier.fillMaxWidth() ) - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f)) + HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) } MessageInput( value = messageText, @@ -434,7 +450,6 @@ fun ChatInputSection( } } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ChatFloatingHeader( headerHeight: Dp, @@ -459,35 +474,38 @@ private fun ChatFloatingHeader( .windowInsetsPadding(WindowInsets.statusBars), // Extend into status bar area color = colorScheme.background // Solid background color extending into status bar ) { - TopAppBar( - title = { - ChatHeaderContent( - selectedPrivatePeer = selectedPrivatePeer, - currentChannel = currentChannel, - nickname = nickname, - viewModel = viewModel, - onBackClick = { - when { - selectedPrivatePeer != null -> viewModel.endPrivateChat() - currentChannel != null -> viewModel.switchToChannel(null) - } - }, - onSidebarClick = onSidebarToggle, - onTripleClick = onPanicClear, - onShowAppInfo = onShowAppInfo, - onLocationChannelsClick = onLocationChannelsClick, - onLocationNotesClick = { - // Ensure location is loaded before showing sheet - locationManager.refreshChannels() - onLocationNotesClick() + // A plain Row rather than M3's TopAppBar. TopAppBar silently injects a 4.dp horizontal + // pad plus a 12.dp title inset and applies its own minimum heights, which made the + // header's spacing impossible to specify exactly. + Row( + modifier = Modifier + .fillMaxWidth() + .height(headerHeight) + .padding(start = 12.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ChatHeaderContent( + selectedPrivatePeer = selectedPrivatePeer, + currentChannel = currentChannel, + nickname = nickname, + viewModel = viewModel, + onBackClick = { + when { + selectedPrivatePeer != null -> viewModel.endPrivateChat() + currentChannel != null -> viewModel.switchToChannel(null) } - ) - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent - ), - modifier = Modifier.height(headerHeight) // Ensure compact header height - ) + }, + onSidebarClick = onSidebarToggle, + onTripleClick = onPanicClear, + onShowAppInfo = onShowAppInfo, + onLocationChannelsClick = onLocationChannelsClick, + onLocationNotesClick = { + // Ensure location is loaded before showing sheet + locationManager.refreshChannels() + onLocationNotesClick() + } + ) + } } } 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 d227eb41..2f945593 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -8,8 +8,8 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.sp import com.bitchat.android.model.BitchatMessage import com.bitchat.android.mesh.MeshService -import androidx.compose.material3.ColorScheme import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.BitchatPalette import java.text.SimpleDateFormat import java.util.* @@ -18,6 +18,15 @@ import java.util.* * Extracted from ChatScreen.kt for better organization */ +/** Opacity applied to the `#abcd` disambiguation suffix so the readable name dominates. */ +internal const val SUFFIX_ALPHA = 0.55f + +/** Background opacity for a mention chip referring to somebody else. */ +internal const val MENTION_CHIP_ALPHA = 0.18f + +/** Background opacity for a mention chip referring to you. Slightly stronger to catch the eye. */ +internal const val MENTION_CHIP_ALPHA_SELF = 0.22f + /** * Get RSSI-based color for signal strength visualization */ @@ -32,146 +41,21 @@ fun getRSSIColor(rssi: Int): Color { } /** - * Format message as annotated string with iOS-style formatting - * Timestamp at END, peer colors, hashtag suffix handling - */ -fun formatMessageAsAnnotatedString( - message: BitchatMessage, - currentUserNickname: String, - meshService: MeshService, - colorScheme: ColorScheme, - timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) -): AnnotatedString { - val builder = AnnotatedString.Builder() - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - - // Determine if this message was sent by self - val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) - - if (message.sender != "system") { - // Get base color for this peer (iOS-style color assignment) - val baseColor = if (isSelf) { - Color(0xFFFF9500) // Orange for self (iOS orange) - } else { - getPeerColor(message, isDark) - } - - // Split sender into base name and hashtag suffix - val (baseName, suffix) = splitSuffix(message.sender) - - // Sender prefix "<@" - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) - builder.append("<@") - builder.pop() - - // Base name (clickable) - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) - val nicknameStart = builder.length - val truncatedBase = truncateNickname(baseName) - builder.append(truncatedBase) - val nicknameEnd = builder.length - - // Add click annotation for nickname (store canonical sender name with hash if available) - if (!isSelf) { - builder.addStringAnnotation( - tag = "nickname_click", - annotation = (message.originalSender ?: message.sender), - start = nicknameStart, - end = nicknameEnd - ) - } - builder.pop() - - // Hashtag suffix in lighter color (iOS style) - if (suffix.isNotEmpty()) { - builder.pushStyle(SpanStyle( - color = baseColor.copy(alpha = 0.6f), - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) - builder.append(suffix) - builder.pop() - } - - // Sender suffix "> " - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) - builder.append("> ") - builder.pop() - - // Message content with iOS-style hashtag and mention highlighting - appendIOSFormattedContent( - builder, - message.content, - message.mentions, - currentUserNickname, - baseColor, - isSelf, - ) - - // iOS-style timestamp at the END (smaller, grey) - // Timestamp (and optional PoW badge) - builder.pushStyle(SpanStyle( - color = Color.Gray.copy(alpha = 0.7f), - fontSize = (BASE_FONT_SIZE - 4).sp - )) - builder.append(" [${timeFormatter.format(message.timestamp)}]") - // If message has valid PoW difficulty, append bits immediately after timestamp with minimal spacing - message.powDifficulty?.let { bits -> - if (bits > 0) { - builder.append(" ⛨${bits}b") - } - } - builder.pop() - - } else { - // System message - iOS style - builder.pushStyle(SpanStyle( - color = Color.Gray, - fontSize = (BASE_FONT_SIZE - 2).sp, - fontStyle = androidx.compose.ui.text.font.FontStyle.Italic - )) - builder.append("* ${message.content} *") - builder.pop() - - // Timestamp for system messages too - builder.pushStyle(SpanStyle( - color = Color.Gray.copy(alpha = 0.5f), - fontSize = (BASE_FONT_SIZE - 4).sp - )) - builder.append(" [${timeFormatter.format(message.timestamp)}]") - builder.pop() - } - - return builder.toAnnotatedString() -} - -/** - * Build the sender label used by the two-row text-message layout. + * Build the sender label shown above the first message of a group. + * + * Renders `@name` plus a dimmed `#abcd` suffix. The name carries a `nickname_click` + * annotation for everyone except yourself. */ fun formatTextMessageSender( message: BitchatMessage, currentUserNickname: String, meshService: MeshService, - colorScheme: ColorScheme + palette: BitchatPalette ): AnnotatedString { val builder = AnnotatedString.Builder() - val isDark = - colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) - val senderColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark) - val senderWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium + val senderColor = if (isSelf) palette.accentOrange else getPeerColor(message, palette.isDark) + val senderWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold val (baseName, suffix) = splitSuffix(message.sender) builder.pushStyle( @@ -198,7 +82,7 @@ fun formatTextMessageSender( if (suffix.isNotEmpty()) { builder.pushStyle( SpanStyle( - color = senderColor.copy(alpha = 0.6f), + color = senderColor.copy(alpha = SUFFIX_ALPHA), fontSize = BASE_FONT_SIZE.sp, fontWeight = senderWeight ) @@ -212,6 +96,9 @@ fun formatTextMessageSender( /** * Build the compact timestamp and optional proof-of-work label. + * + * Used standalone by media rows; text messages get the same span appended inline to the end of + * their body via [appendTrailingTimestamp]. */ fun formatTextMessageMetadata( message: BitchatMessage, @@ -233,66 +120,124 @@ fun formatTextMessageMetadata( } /** - * Build only the message body while retaining mention, URL and geohash styling. + * Append the timestamp (and optional PoW difficulty) directly after the message body so it + * trails the final words rather than occupying its own column. + * + * Deliberately carries no click annotation: the timestamp is decoration, and making it + * tappable would create dead zones inside the message body. + */ +private fun appendTrailingTimestamp( + builder: AnnotatedString.Builder, + message: BitchatMessage, + palette: BitchatPalette, + timeFormatter: SimpleDateFormat +) { + builder.pushStyle( + SpanStyle( + color = palette.textTertiary, + fontSize = (BASE_FONT_SIZE - 4).sp, + fontWeight = FontWeight.Normal + ) + ) + builder.append(" ") + builder.append(timeFormatter.format(message.timestamp)) + message.powDifficulty?.takeIf { it > 0 }?.let { bits -> + builder.append(" ⛨${bits}b") + } + builder.pop() +} + +/** + * Build the message body: neutral text with mention/URL/geohash accents, followed by an inline + * trailing timestamp. + * + * Body text is intentionally neutral rather than peer-colored. Colour is reserved for + * `@names`, which is what makes a busy channel scannable. */ fun formatTextMessageBody( message: BitchatMessage, currentUserNickname: String, - meshService: MeshService, - colorScheme: ColorScheme + palette: BitchatPalette, + timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()), + includeTimestamp: Boolean = true ): AnnotatedString { val builder = AnnotatedString.Builder() - val isDark = - colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) - val accentColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark) appendIOSFormattedContent( builder = builder, content = message.content, - mentions = message.mentions, currentUserNickname = currentUserNickname, - baseColor = accentColor, - isSelf = isSelf, - contentColor = colorScheme.onSurface + palette = palette, ) + + if (includeTimestamp) { + appendTrailingTimestamp(builder, message, palette, timeFormatter) + } return builder.toAnnotatedString() } /** - * Build only the nickname + timestamp header line for a message, matching styles of normal messages. + * Build a system / background-action line, e.g. `// Tor started. Routing all chats… 11:09:56`. + * + * The `//` prefix reads as machine narration in a monospace context and is far quieter than + * the previous `* italic asterisk *` treatment, which competed with real messages. + */ +fun formatSystemMessage( + message: BitchatMessage, + palette: BitchatPalette, + timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) +): AnnotatedString { + val builder = AnnotatedString.Builder() + builder.pushStyle( + SpanStyle( + color = palette.textSecondary, + fontSize = (BASE_FONT_SIZE - 2).sp, + fontWeight = FontWeight.Normal + ) + ) + builder.append("// ") + builder.append(message.content) + builder.pop() + + appendTrailingTimestamp(builder, message, palette, timeFormatter) + return builder.toAnnotatedString() +} + +/** + * Header line for media (image / audio / file) rows. + * + * Matches the text-message treatment: `@name#abcd` with no angle brackets, followed by an + * inline trailing timestamp. Media rows have no body text to trail, so the timestamp sits on + * the same line as the name. */ fun formatMessageHeaderAnnotatedString( message: BitchatMessage, currentUserNickname: String, meshService: MeshService, - colorScheme: ColorScheme, - timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + palette: BitchatPalette, + timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()), + includeSender: Boolean = true ): AnnotatedString { val builder = AnnotatedString.Builder() - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) - if (message.sender != "system") { - val baseColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark) + if (message.sender == "system") { + return formatSystemMessage(message, palette, timeFormatter) + } + + if (includeSender) { + val baseColor = if (isSelf) palette.accentOrange else getPeerColor(message, palette.isDark) + val weight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold val (baseName, suffix) = splitSuffix(message.sender) - // "<@" - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) - builder.append("<@") - builder.pop() - - // Base name (clickable when not self) - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) + builder.pushStyle( + SpanStyle( + color = baseColor, + fontSize = BASE_FONT_SIZE.sp, + fontWeight = weight + ) + ) + builder.append("@") val nicknameStart = builder.length builder.append(truncateNickname(baseName)) val nicknameEnd = builder.length @@ -306,53 +251,20 @@ fun formatMessageHeaderAnnotatedString( } builder.pop() - // Hashtag suffix if (suffix.isNotEmpty()) { - builder.pushStyle(SpanStyle( - color = baseColor.copy(alpha = 0.6f), - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) + builder.pushStyle( + SpanStyle( + color = baseColor.copy(alpha = SUFFIX_ALPHA), + fontSize = BASE_FONT_SIZE.sp, + fontWeight = weight + ) + ) builder.append(suffix) builder.pop() } - - // Sender suffix ">" - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) - builder.append(">") - builder.pop() - - // Timestamp and optional PoW bits, matching normal message appearance - builder.pushStyle(SpanStyle( - color = Color.Gray.copy(alpha = 0.7f), - fontSize = (BASE_FONT_SIZE - 4).sp - )) - builder.append(" [${timeFormatter.format(message.timestamp)}]") - message.powDifficulty?.let { bits -> - if (bits > 0) builder.append(" ⛨${bits}b") - } - builder.pop() - } else { - // System message header (should rarely apply to voice) - builder.pushStyle(SpanStyle( - color = Color.Gray, - fontSize = (BASE_FONT_SIZE - 2).sp, - fontStyle = androidx.compose.ui.text.font.FontStyle.Italic - )) - builder.append("* ${message.content} *") - builder.pop() - builder.pushStyle(SpanStyle( - color = Color.Gray.copy(alpha = 0.5f), - fontSize = (BASE_FONT_SIZE - 4).sp - )) - builder.append(" [${timeFormatter.format(message.timestamp)}]") - builder.pop() } + appendTrailingTimestamp(builder, message, palette, timeFormatter) return builder.toAnnotatedString() } @@ -385,7 +297,12 @@ fun getPeerColor(message: BitchatMessage, isDark: Boolean): Color { } /** - * Generate consistent peer color using djb2 hash (matches iOS algorithm exactly) + * Generate consistent peer color using djb2 hash. + * + * The hash and hue derivation are byte-identical to the iOS implementation, so a given peer + * resolves to the same hue on both platforms. Saturation and value are deliberately higher + * than iOS: the redesigned chat surface renders message bodies in neutral near-white, so + * nicknames need more chroma to stay distinguishable at a glance. */ fun colorForPeerSeed(seed: String, isDark: Boolean): Color { // djb2 hash algorithm (matches iOS implementation) @@ -402,8 +319,8 @@ fun colorForPeerSeed(seed: String, isDark: Boolean): Color { hue = (hue + 0.12) % 1.0 } - val saturation = if (isDark) 0.50 else 0.70 - val brightness = if (isDark) 0.85 else 0.35 + val saturation = if (isDark) 0.62 else 0.85 + val brightness = if (isDark) 0.92 else 0.45 return Color.hsv( hue = (hue * 360).toFloat(), @@ -430,17 +347,27 @@ fun splitSuffix(name: String): Pair { } /** - * iOS-style content formatting with proper hashtag and mention handling + * iOS-style content formatting with proper hashtag and mention handling. + * + * Redesign notes: + * - Plain text renders in [BitchatPalette.textPrimary]; colour is reserved for `@mentions`, + * links and geohashes. + * - Mentions get a tinted background chip so they read as a distinct token inside a sentence. + * The chip is tinted by *the mentioned peer's* colour, not the sender's, so `@alice` looks + * the same everywhere she is referenced. + * - Neither "self" nor "you were mentioned" bolds the whole body any more. Bolding entire + * paragraphs was the single biggest source of visual noise in the old layout; the mention + * chip carries that emphasis instead. */ private fun appendIOSFormattedContent( builder: AnnotatedString.Builder, content: String, - mentions: List?, currentUserNickname: String, - baseColor: Color, - isSelf: Boolean, - contentColor: Color = baseColor, + palette: BitchatPalette, ) { + val contentColor = palette.textPrimary + val linkColor = palette.accentBlue + // iOS-style patterns: allow optional '#abcd' suffix in mentions val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex() val mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)".toRegex() @@ -507,28 +434,28 @@ private fun appendIOSFormattedContent( } allMatches.sortBy { it.first.first } - + + val plainStyle = SpanStyle( + color = contentColor, + fontSize = BASE_FONT_SIZE.sp, + fontWeight = FontWeight.Normal + ) + val linkStyle = SpanStyle( + color = linkColor, + fontSize = BASE_FONT_SIZE.sp, + fontWeight = FontWeight.Normal, + textDecoration = TextDecoration.Underline + ) + var lastEnd = 0 - val isMentioned = mentions?.contains(currentUserNickname) == true - + for ((range, type) in allMatches) { // Add text before match if (lastEnd < range.first) { val beforeText = content.substring(lastEnd, range.first) if (beforeText.isNotEmpty()) { - builder.pushStyle(SpanStyle( - color = contentColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal - )) - if (isMentioned) { - // Make entire message bold if user is mentioned - builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) - builder.append(beforeText) - builder.pop() - } else { - builder.append(beforeText) - } + builder.pushStyle(plainStyle) + builder.append(beforeText) builder.pop() } } @@ -540,65 +467,55 @@ private fun appendIOSFormattedContent( // iOS-style mention with hashtag suffix support val mentionWithoutAt = matchText.removePrefix("@") val (mBase, mSuffix) = splitSuffix(mentionWithoutAt) - - // Check if this mention targets current user + + // Mentions targeting you are the one thing worth shouting about. val isMentionToMe = mBase == currentUserNickname - val mentionColor = if (isMentionToMe) Color(0xFFFF9500) else baseColor - - // "@" symbol + val mentionColor = if (isMentionToMe) { + palette.accentOrange + } else { + // Tint by the *mentioned* peer so a given name looks identical everywhere. + colorForPeerSeed(mentionWithoutAt.lowercase(), palette.isDark) + } + val chipAlpha = if (isMentionToMe) MENTION_CHIP_ALPHA_SELF else MENTION_CHIP_ALPHA + val mentionWeight = if (isMentionToMe) FontWeight.Bold else FontWeight.SemiBold + + // A single outer span carrying the background makes the chip render as one + // continuous rectangle. Pushing the background per-token would leave hairline + // seams between "@", the name and the "#abcd" suffix. + builder.pushStyle(SpanStyle(background = mentionColor.copy(alpha = chipAlpha))) + builder.pushStyle(SpanStyle( color = mentionColor, fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold + fontWeight = mentionWeight )) builder.append("@") - builder.pop() - - // Base name (truncate for rendering) - builder.pushStyle(SpanStyle( - color = mentionColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold - )) builder.append(truncateNickname(mBase)) builder.pop() - + // Hashtag suffix in lighter color if (mSuffix.isNotEmpty()) { builder.pushStyle(SpanStyle( - color = mentionColor.copy(alpha = 0.6f), + color = mentionColor.copy(alpha = SUFFIX_ALPHA), fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold + fontWeight = mentionWeight )) builder.append(mSuffix) builder.pop() } + + builder.pop() // background chip } "hashtag" -> { // Render general hashtags like normal content - builder.pushStyle(SpanStyle( - color = contentColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal - )) - if (isMentioned) { - builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) - builder.append(matchText) - builder.pop() - } else { - builder.append(matchText) - } + builder.pushStyle(plainStyle) + builder.append(matchText) builder.pop() } else -> { if (type == "geohash") { - // Style geohash in blue, underlined, and add click annotation - builder.pushStyle(SpanStyle( - color = Color(0xFF007AFF), - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold, - textDecoration = TextDecoration.Underline - )) + // Style geohash as a link and add click annotation + builder.pushStyle(linkStyle) val start = builder.length builder.append(matchText) val end = builder.length @@ -611,13 +528,8 @@ private fun appendIOSFormattedContent( ) builder.pop() } else if (type == "url") { - // Style URL in blue, underlined, and add click annotation with the raw text - builder.pushStyle(SpanStyle( - color = Color(0xFF007AFF), - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold, - textDecoration = TextDecoration.Underline - )) + // Style URL as a link and add click annotation with the raw text + builder.pushStyle(linkStyle) val start = builder.length builder.append(matchText) val end = builder.length @@ -630,11 +542,7 @@ private fun appendIOSFormattedContent( builder.pop() } else { // Fallback: treat as normal text - builder.pushStyle(SpanStyle( - color = contentColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal - )) + builder.pushStyle(plainStyle) builder.append(matchText) builder.pop() } @@ -647,18 +555,8 @@ private fun appendIOSFormattedContent( // Add remaining text if (lastEnd < content.length) { val remainingText = content.substring(lastEnd) - builder.pushStyle(SpanStyle( - color = contentColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal - )) - if (isMentioned) { - builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) - builder.append(remainingText) - builder.pop() - } else { - builder.append(remainingText) - } + builder.pushStyle(plainStyle) + builder.append(remainingText) builder.pop() } } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 0bdc85ee..1eccc152 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -3,6 +3,7 @@ package com.bitchat.android.ui import android.util.Log import androidx.compose.foundation.* import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.Explore @@ -17,6 +18,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.LocalBitchatPalette import java.util.* import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.res.stringResource @@ -52,41 +54,21 @@ fun GeohashPeopleList( val nickname by viewModel.nickname.collectAsStateWithLifecycle() val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() - Column { - // Header matching iOS style - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.LocationOn, - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = stringResource(R.string.geohash_people_header), - style = MaterialTheme.typography.labelSmall.copy( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold - ), - color = colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - + val palette = LocalBitchatPalette.current + + Column(modifier = modifier) { if (geohashPeople.isEmpty()) { + SheetSectionLabel(text = stringResource(R.string.section_on_location)) // Empty state - matches iOS "nobody around..." Text( text = stringResource(R.string.nobody_around), - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp - ), - color = colorScheme.onSurface.copy(alpha = 0.5f), - modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp) + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = palette.textTertiary, + modifier = Modifier.padding( + horizontal = SheetHorizontalPadding + 14.dp, + vertical = 12.dp + ) ) } else { // Get current geohash identity for "me" detection @@ -129,12 +111,20 @@ fun GeohashPeopleList( counts } - val firstID = orderedPeople.firstOrNull()?.id - - orderedPeople.forEach { person -> + // Split by presence: people physically in the geohash versus people who teleported + // in. Mixing them hid the fact that a "nearby" channel can contain remote users. + fun personTeleported(person: GeoPerson): Boolean = if (person.id == myHex) { + isTeleported + } else { + viewModel.isPersonTeleported(person.id) + } + + val (teleportedPeople, localPeople) = orderedPeople.partition { personTeleported(it) } + + @Composable + fun personRow(person: GeoPerson) { GeohashPersonItem( person = person, - isFirst = person.id == firstID, isMe = myHex != null && person.id == myHex, hasUnreadDM = unreadPrivateMessages.contains("nostr_${person.id.take(16)}"), isTeleported = person.id != myHex && viewModel.isPersonTeleported(person.id), @@ -153,6 +143,16 @@ fun GeohashPeopleList( } ) } + + if (localPeople.isNotEmpty()) { + SheetSectionLabel(text = stringResource(R.string.section_on_location)) + localPeople.forEach { personRow(it) } + } + + if (teleportedPeople.isNotEmpty()) { + SheetSectionLabel(text = stringResource(R.string.section_teleported_in)) + teleportedPeople.forEach { personRow(it) } + } } } } @@ -160,7 +160,6 @@ fun GeohashPeopleList( @Composable private fun GeohashPersonItem( person: GeoPerson, - isFirst: Boolean, isMe: Boolean, hasUnreadDM: Boolean, isTeleported: Boolean, @@ -171,12 +170,21 @@ private fun GeohashPersonItem( showHashSuffix: Boolean, onTap: () -> Unit ) { + val palette = LocalBitchatPalette.current + + Surface( + onClick = onTap, + color = palette.surface, + shape = RoundedCornerShape(10.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) + ) { Row( modifier = Modifier .fillMaxWidth() - .clickable { onTap() } - .padding(horizontal = 24.dp, vertical = 4.dp) - .padding(top = if (isFirst) 10.dp else 0.dp), + .heightIn(min = 44.dp) + .padding(horizontal = 14.dp), verticalAlignment = Alignment.CenterVertically ) { // Icon logic matching iOS exactly @@ -185,16 +193,16 @@ private fun GeohashPersonItem( Icon( imageVector = Icons.Filled.Email, contentDescription = stringResource(R.string.cd_unread_message), - modifier = Modifier.size(12.dp), - tint = Color(0xFFFF9500) // iOS orange + modifier = Modifier.size(16.dp), + tint = palette.accentOrange ) } else { // Face icon with teleportation state val (iconName, iconColor) = when { - isMe && isMyTeleported -> "face.dashed" to Color(0xFFFF9500) // Orange for teleported me - isTeleported -> "face.dashed" to colorScheme.onSurface // Regular color for teleported others - isMe -> "face.smiling" to Color(0xFFFF9500) // Orange for me - else -> "face.smiling" to colorScheme.onSurface // Regular color for others + isMe && isMyTeleported -> "face.dashed" to palette.accentOrange + isTeleported -> "face.dashed" to palette.textSecondary + isMe -> "face.smiling" to palette.accentOrange + else -> "face.smiling" to palette.textSecondary } // Use appropriate Material icon (closest match to iOS SF Symbols) @@ -206,7 +214,7 @@ private fun GeohashPersonItem( Icon( imageVector = icon, contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User", - modifier = Modifier.size(12.dp), + modifier = Modifier.size(16.dp), tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) // Make dashed faces slightly transparent ) } @@ -219,9 +227,8 @@ private fun GeohashPersonItem( val suffix = if (showHashSuffix) suffixRaw else "" // Get consistent peer color (matches iOS color assignment exactly) - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val assignedColor = viewModel.colorForNostrPubkey(person.id, isDark) - val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor + val assignedColor = viewModel.colorForNostrPubkey(person.id, palette.isDark) + val baseColor = if (isMe) palette.accentOrange else assignedColor Row( modifier = Modifier.weight(1f), @@ -230,42 +237,36 @@ private fun GeohashPersonItem( // Base name with peer-specific color Text( text = baseName, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal - ), + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, color = baseColor, maxLines = 1, overflow = TextOverflow.Ellipsis ) - + // Suffix (collision-resistant #abcd) in lighter shade if (suffix.isNotEmpty()) { Text( text = suffix, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp - ), - color = baseColor.copy(alpha = 0.6f) + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, + color = baseColor.copy(alpha = SUFFIX_ALPHA) ) } - + // "You" indicator for current user if (isMe) { Text( text = stringResource(R.string.you_suffix), - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp - ), + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, color = baseColor ) } } - - Spacer(modifier = Modifier.width(8.dp)) + } } } diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index 91555b38..3fac4761 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -2,6 +2,9 @@ package com.bitchat.android.ui // [Goose] TODO: Replace inline file attachment stub with FilePickerButton abstraction that dispatches via FileShareDispatcher +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape @@ -15,6 +18,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource @@ -38,6 +42,8 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.withStyle import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette import com.bitchat.android.features.voice.normalizeAmplitudeSample import com.bitchat.android.features.voice.AudioWaveformExtractor import com.bitchat.android.ui.media.RealtimeScrollingWaveform @@ -53,7 +59,10 @@ import com.bitchat.android.ui.media.FilePickerButton * VisualTransformation that styles slash commands with background and color * while preserving cursor positioning and click handling */ -class SlashCommandVisualTransformation : VisualTransformation { +class SlashCommandVisualTransformation( + private val commandColor: Color, + private val commandBackground: Color, +) : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { val slashCommandRegex = Regex("(/\\w+)(?=\\s|$)") val annotatedString = buildAnnotatedString { @@ -68,10 +77,10 @@ class SlashCommandVisualTransformation : VisualTransformation { // Add the styled slash command withStyle( style = SpanStyle( - color = Color(0xFF00FF7F), // Bright green + color = commandColor, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium, - background = Color(0xFF2D2D2D) // Dark gray background + background = commandBackground ) ) { append(match.value) @@ -97,7 +106,10 @@ class SlashCommandVisualTransformation : VisualTransformation { * VisualTransformation that styles mentions with background and color * while preserving cursor positioning and click handling */ -class MentionVisualTransformation : VisualTransformation { +class MentionVisualTransformation( + private val mentionColor: Color, + private val mentionBackground: Color, +) : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { val mentionRegex = Regex("@([a-zA-Z0-9_]+)") val annotatedString = buildAnnotatedString { @@ -112,9 +124,12 @@ class MentionVisualTransformation : VisualTransformation { // Add the styled mention withStyle( style = SpanStyle( - color = Color(0xFFFF9500), // Orange + color = mentionColor, fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, + // Mirrors the mention chip used in rendered messages, so what you type + // looks like what everyone will see. + background = mentionBackground ) ) { append(match.value) @@ -159,6 +174,24 @@ class CombinedVisualTransformation(private val transformations: List - isFocused.value = focusState.isFocused - } - ) + // Recording turns the pill's outline red; a cross-fade keeps that from flashing. + val borderColor by animateColorAsState( + targetValue = when { + isRecording -> palette.accentRed.copy(alpha = 0.6f) + isFocused.value -> palette.outline + else -> palette.outlineVariant + }, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerBorder" + ) - // Show placeholder when there's no text and not recording - if (value.text.isEmpty() && !isRecording) { - Text( - text = stringResource(R.string.type_a_message_placeholder), - style = MaterialTheme.typography.bodyMedium.copy( + Row( + modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), + // Bottom-aligned so that when the field grows to several lines the send button and the + // media buttons stay pinned next to the last line, as in the design's long-text state. + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + // MARK: - The pill. Holds the field and the send button as one visual object. + Row( + modifier = Modifier + .weight(1f) + .heightIn(min = ComposerMinHeight) + .background(palette.surfaceVariant, ComposerShape) + .border(1.dp, borderColor, ComposerShape), + verticalAlignment = Alignment.Bottom + ) { + Box( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 12.dp) + ) { + // Always keep the text field mounted to retain focus and avoid IME collapse + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = MaterialTheme.typography.bodyMedium.copy( + color = colorScheme.primary, fontFamily = FontFamily.Monospace ), - color = colorScheme.onSurface.copy(alpha = 0.5f), // Muted grey - modifier = Modifier.fillMaxWidth() + cursorBrush = SolidColor(if (isRecording) Color.Transparent else colorScheme.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { + if (hasText) onSend() // Only send if there's text + }), + // Cap the growth so a pasted wall of text cannot swallow the message list. + maxLines = 6, + visualTransformation = remember(palette) { + CombinedVisualTransformation( + listOf( + SlashCommandVisualTransformation( + commandColor = palette.accentGreen, + commandBackground = palette.accentGreen.copy(alpha = 0.14f), + ), + MentionVisualTransformation( + mentionColor = palette.accentOrange, + mentionBackground = palette.accentOrange.copy(alpha = MENTION_CHIP_ALPHA), + ), + ) + ) + }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + isFocused.value = focusState.isFocused + } ) - } - // Overlay the real-time scrolling waveform while recording - if (isRecording) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - RealtimeScrollingWaveform( - modifier = Modifier.weight(1f).height(32.dp), - amplitudeNorm = normalizeAmplitudeSample(amplitude) - ) - Spacer(Modifier.width(20.dp)) - val secs = (elapsedMs / 1000).toInt() - val mm = secs / 60 - val ss = secs % 60 - val maxSecs = 10 // 10 second max recording time - val maxMm = maxSecs / 60 - val maxSs = maxSecs % 60 + // Show placeholder when there's no text and not recording + if (value.text.isEmpty() && !isRecording) { Text( - text = String.format("%02d:%02d / %02d:%02d", mm, ss, maxMm, maxSs), - fontFamily = FontFamily.Monospace, - color = colorScheme.primary, - fontSize = (BASE_FONT_SIZE - 4).sp + text = stringResource(R.string.type_a_message_placeholder), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace + ), + color = palette.textTertiary, + modifier = Modifier.fillMaxWidth() ) } - } - } - - Spacer(modifier = Modifier.width(8.dp)) // Reduced spacing - - // Voice and image buttons when no text (only visible in Mesh chat) - if (value.text.isEmpty() && showMediaButtons) { - // Hold-to-record microphone - val bg = if (colorScheme.background == Color.Black) Color(0xFF00FF00).copy(alpha = 0.75f) else Color(0xFF008000).copy(alpha = 0.75f) + // Overlay the real-time scrolling waveform while recording + if (isRecording) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + RealtimeScrollingWaveform( + modifier = Modifier.weight(1f).height(24.dp), + amplitudeNorm = normalizeAmplitudeSample(amplitude) + ) + Spacer(Modifier.width(12.dp)) + val secs = (elapsedMs / 1000).toInt() + val mm = secs / 60 + val ss = secs % 60 + val maxSecs = 10 // 10 second max recording time + val maxMm = maxSecs / 60 + val maxSs = maxSecs % 60 + Text( + text = String.format("%02d:%02d / %02d:%02d", mm, ss, maxMm, maxSs), + fontFamily = FontFamily.Monospace, + color = palette.accentRed, + fontSize = (BASE_FONT_SIZE - 4).sp + ) + } + } + } + + SendButton( + hasText = hasText, + isAccented = selectedPrivatePeer != null || currentChannel != null, + onSend = onSend, + modifier = Modifier.padding(end = 6.dp, bottom = 6.dp) + ) + } + + // MARK: - Media affordances, outside the pill, only while the field is empty. + if (value.text.isEmpty() && showMediaButtons) { // Ensure latest values are used when finishing recording val latestSelectedPeer = rememberUpdatedState(selectedPrivatePeer) val latestChannel = rememberUpdatedState(currentChannel) @@ -265,26 +338,15 @@ fun MessageInput( // Image button (image picker) - hide during recording if (!isRecording) { - // Revert to original separate buttons: round File button (left) and the old Image plus button (right) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { - // DISABLE FILE PICKER - //FilePickerButton( - // onFileReady = { path -> - // onSendFileNote(latestSelectedPeer.value, latestChannel.value, path) - // } - //) - ImagePickerButton( - onImageReady = { outPath -> - onSendImageNote(latestSelectedPeer.value, latestChannel.value, outPath) - } - ) - } + ImagePickerButton( + onImageReady = { outPath -> + onSendImageNote(latestSelectedPeer.value, latestChannel.value, outPath) + } + ) } - Spacer(Modifier.width(1.dp)) - VoiceRecordButton( - backgroundColor = bg, + backgroundColor = colorScheme.primary, onStart = { isRecording = true elapsedMs = 0L @@ -313,72 +375,81 @@ fun MessageInput( ) } ) - - } else { - // Send button with enabled/disabled state - IconButton( - onClick = { if (hasText) onSend() }, // Only execute if there's text - enabled = hasText, // Enable only when there's text - modifier = Modifier.size(32.dp) - ) { - // Update send button to match input field colors - Box( - modifier = Modifier - .size(30.dp) - .background( - color = if (!hasText) { - // Disabled state - muted grey - colorScheme.onSurface.copy(alpha = 0.3f) - } else if (selectedPrivatePeer != null || currentChannel != null) { - // Orange for both private messages and channels when enabled - Color(0xFFFF9500).copy(alpha = 0.75f) - } else if (colorScheme.background == Color.Black) { - Color(0xFF00FF00).copy(alpha = 0.75f) // Bright green for dark theme - } else { - Color(0xFF008000).copy(alpha = 0.75f) // Dark green for light theme - }, - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Filled.ArrowUpward, - contentDescription = stringResource(id = R.string.send_message), - modifier = Modifier.size(20.dp), - tint = if (!hasText) { - // Disabled state - muted grey icon - colorScheme.onSurface.copy(alpha = 0.5f) - } else if (selectedPrivatePeer != null || currentChannel != null) { - // Black arrow on orange for both private and channel modes - Color.Black - } else if (colorScheme.background == Color.Black) { - Color.Black // Black arrow on bright green in dark theme - } else { - Color.White // White arrow on dark green in light theme - } - ) - } - } } } // Auto-stop handled inside VoiceRecordButton } +/** + * Circular send affordance nested in the bottom-right of the composer pill. + * + * Goes from a flat grey disc to a solid accent the moment there is something to send, which is + * the clearest possible signal that the return key will do something. + */ +@Composable +private fun SendButton( + hasText: Boolean, + isAccented: Boolean, + onSend: () -> Unit, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + + val targetBackground = when { + !hasText -> palette.outline + isAccented -> palette.accentOrange + else -> colorScheme.primary + } + val targetTint = when { + !hasText -> palette.textTertiary + // Both accents are bright enough that black is the only legible arrow colour. + else -> Color.Black + } + + val background by animateColorAsState( + targetValue = targetBackground, + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), + label = "sendButtonBackground" + ) + val tint by animateColorAsState( + targetValue = targetTint, + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), + label = "sendButtonTint" + ) + + Box( + modifier = modifier + .size(SendButtonSize) + .background(background, CircleShape) + .clip(CircleShape) + .clickable(enabled = hasText) { onSend() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.ArrowUpward, + contentDescription = stringResource(id = R.string.send_message), + modifier = Modifier.size(18.dp), + tint = tint + ) + } +} + @Composable fun CommandSuggestionsBox( suggestions: List, onSuggestionClick: (CommandSuggestion) -> Unit, modifier: Modifier = Modifier ) { - val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current Column( modifier = modifier .verticalScroll(rememberScrollState()) - .background(colorScheme.surface) - .border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp)) - .padding(vertical = 8.dp) + .background(palette.surface) + .border(1.dp, palette.outlineVariant, RoundedCornerShape(8.dp)) + .padding(vertical = 6.dp) ) { suggestions.forEach { suggestion: CommandSuggestion -> CommandSuggestionItem( @@ -395,13 +466,14 @@ fun CommandSuggestionItem( onClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current Row( modifier = Modifier .fillMaxWidth() .clickable { onClick() } - .padding(horizontal = 12.dp, vertical = 3.dp) - .background(Color.Gray.copy(alpha = 0.1f)), + // Roomier rows: at 3.dp vertical these were below a comfortable tap height. + .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -419,7 +491,7 @@ fun CommandSuggestionItem( fontWeight = FontWeight.Medium ), color = colorScheme.primary, - fontSize = (BASE_FONT_SIZE - 4).sp + fontSize = (BASE_FONT_SIZE - 2).sp ) // Show syntax if any @@ -429,8 +501,8 @@ fun CommandSuggestionItem( style = MaterialTheme.typography.bodySmall.copy( fontFamily = FontFamily.Monospace ), - color = colorScheme.onSurface.copy(alpha = 0.8f), - fontSize = (BASE_FONT_SIZE - 5).sp + color = palette.textSecondary, + fontSize = (BASE_FONT_SIZE - 4).sp ) } @@ -440,8 +512,8 @@ fun CommandSuggestionItem( style = MaterialTheme.typography.bodySmall.copy( fontFamily = FontFamily.Monospace ), - color = colorScheme.onSurface.copy(alpha = 0.7f), - fontSize = (BASE_FONT_SIZE - 5).sp, + color = palette.textTertiary, + fontSize = (BASE_FONT_SIZE - 4).sp, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -454,13 +526,13 @@ fun MentionSuggestionsBox( onSuggestionClick: (String) -> Unit, modifier: Modifier = Modifier ) { - val colorScheme = MaterialTheme.colorScheme - + val palette = LocalBitchatPalette.current + Column( modifier = modifier - .background(colorScheme.surface) - .border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp)) - .padding(vertical = 8.dp) + .background(palette.surface) + .border(1.dp, palette.outlineVariant, RoundedCornerShape(8.dp)) + .padding(vertical = 6.dp) ) { suggestions.forEach { suggestion: String -> MentionSuggestionItem( @@ -476,14 +548,13 @@ fun MentionSuggestionItem( suggestion: String, onClick: () -> Unit ) { - val colorScheme = MaterialTheme.colorScheme - + val palette = LocalBitchatPalette.current + Row( modifier = Modifier .fillMaxWidth() .clickable { onClick() } - .padding(horizontal = 12.dp, vertical = 3.dp) - .background(Color.Gray.copy(alpha = 0.1f)), + .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { Text( @@ -492,19 +563,19 @@ fun MentionSuggestionItem( fontFamily = FontFamily.Monospace, fontWeight = FontWeight.SemiBold ), - color = Color(0xFFFF9500), // Orange like mentions - fontSize = (BASE_FONT_SIZE - 4).sp + color = palette.accentOrange, + fontSize = (BASE_FONT_SIZE - 2).sp ) - + Spacer(modifier = Modifier.weight(1f)) - + Text( text = stringResource(R.string.mention), style = MaterialTheme.typography.bodySmall.copy( fontFamily = FontFamily.Monospace ), - color = colorScheme.onSurface.copy(alpha = 0.7f), - fontSize = (BASE_FONT_SIZE - 5).sp + color = palette.textTertiary, + fontSize = (BASE_FONT_SIZE - 4).sp ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 99ed10cc..26cdb767 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -3,11 +3,17 @@ package com.bitchat.android.ui import android.content.Intent import android.net.Uri import android.provider.Settings +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark @@ -15,6 +21,7 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Map import androidx.compose.material.icons.filled.PinDrop import androidx.compose.material.icons.outlined.BookmarkBorder +import androidx.compose.material.icons.outlined.Public import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -41,6 +48,11 @@ import com.bitchat.android.R import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle +import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.net.TorMode +import com.bitchat.android.net.TorPreferenceManager +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette /** * Location Channels Sheet for selecting geohash-based location channels @@ -109,11 +121,10 @@ fun LocationChannelsSheet( } } - // iOS system colors (matches iOS exactly) val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green - val standardBlue = Color(0xFF007AFF) // iOS blue + val palette = LocalBitchatPalette.current + val standardGreen = palette.accentGreen + val standardBlue = palette.accentBlue if (isPresented) { BitchatBottomSheet( @@ -129,14 +140,28 @@ fun LocationChannelsSheet( ) { // Header Section item(key = "header") { - Text( - text = stringResource(R.string.location_channels_desc), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), + Column( modifier = Modifier - .padding(horizontal = 24.dp) - ) + .fillMaxWidth() + .padding(horizontal = SheetHorizontalPadding + 14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + SheetHeaderBadge(icon = Icons.Outlined.Public) + Text( + text = stringResource(R.string.location_channels_heading), + fontSize = 20.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = colorScheme.primary + ) + Text( + text = stringResource(R.string.location_channels_desc), + fontSize = 12.sp, + lineHeight = 17.sp, + fontFamily = FontFamily.Monospace, + color = palette.textSecondary + ) + } } // Permission controls if services enabled @@ -156,7 +181,7 @@ fun LocationChannelsSheet( text = stringResource(R.string.location_permission_denied), fontSize = 11.sp, fontFamily = FontFamily.Monospace, - color = Color.Red.copy(alpha = 0.8f) + color = palette.accentRed ) TextButton( onClick = { @@ -208,6 +233,11 @@ fun LocationChannelsSheet( // iOS: let nearby = manager.availableChannels.filter { $0.level != .building } if (availableChannels.isNotEmpty() && locationServicesEnabled) { val nearbyChannels = availableChannels.filter { it.level != GeohashChannelLevel.BUILDING } + if (nearbyChannels.isNotEmpty()) { + item(key = "nearby_header") { + SheetSectionLabel(text = stringResource(R.string.section_nearby)) + } + } items(nearbyChannels) { channel -> val coverage = coverageString(channel.geohash.length) val nameBase = locationNames[channel.level] @@ -224,13 +254,14 @@ fun LocationChannelsSheet( titleColor = standardGreen, titleBold = highlight, trailingContent = { - IconButton(onClick = { bookmarksStore.toggle(channel.geohash) }) { - Icon( - imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, - contentDescription = if (isBookmarked) stringResource(R.string.cd_remove_bookmark) else stringResource(R.string.cd_add_bookmark), - tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), - ) - } + IconButton(onClick = { bookmarksStore.toggle(channel.geohash) }) { + Icon( + imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, + contentDescription = if (isBookmarked) stringResource(R.string.cd_remove_bookmark) else stringResource(R.string.cd_add_bookmark), + tint = if (isBookmarked) palette.accentGreen else palette.textSecondary, + modifier = Modifier.size(18.dp) + ) + } }, onClick = { // Selecting a suggested nearby channel is not a teleport @@ -252,12 +283,13 @@ fun LocationChannelsSheet( CircularProgressIndicator( modifier = Modifier.size(16.dp), strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + color = palette.textSecondary ) Text( text = stringResource(R.string.finding_nearby_channels), fontSize = 12.sp, - fontFamily = FontFamily.Monospace + fontFamily = FontFamily.Monospace, + color = palette.textSecondary ) } } @@ -266,16 +298,7 @@ fun LocationChannelsSheet( // Bookmarked geohashes if (bookmarks.isNotEmpty()) { item(key = "bookmarked_header") { - Text( - text = stringResource(R.string.bookmarked), - style = MaterialTheme.typography.labelLarge, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(top = 8.dp, bottom = 4.dp) - ) + SheetSectionLabel(text = stringResource(R.string.section_bookmarked)) } items(bookmarks) { gh -> val level = levelForLength(gh.length) @@ -298,7 +321,8 @@ fun LocationChannelsSheet( Icon( imageVector = Icons.Filled.Bookmark, contentDescription = stringResource(R.string.cd_remove_bookmark), - tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + tint = palette.accentGreen, + modifier = Modifier.size(18.dp) ) } }, @@ -321,16 +345,17 @@ fun LocationChannelsSheet( // Custom geohash teleport (iOS-style inline form) item(key = "custom_geohash") { Surface( - color = Color.Transparent, - shape = MaterialTheme.shapes.medium, + color = palette.surface, + shape = RoundedCornerShape(10.dp), modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 2.dp) + .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), + .heightIn(min = 44.dp) + .padding(start = 14.dp, end = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -338,7 +363,7 @@ fun LocationChannelsSheet( text = stringResource(R.string.hash_symbol), fontSize = BASE_FONT_SIZE.sp, fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + color = palette.textTertiary ) BasicTextField( @@ -358,8 +383,9 @@ fun LocationChannelsSheet( textStyle = androidx.compose.ui.text.TextStyle( fontSize = BASE_FONT_SIZE.sp, fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface + color = colorScheme.primary ), + cursorBrush = androidx.compose.ui.graphics.SolidColor(colorScheme.primary), modifier = Modifier .weight(1f) .onFocusChanged { focusState -> @@ -381,7 +407,7 @@ fun LocationChannelsSheet( text = stringResource(R.string.geohash_placeholder), fontSize = BASE_FONT_SIZE.sp, fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + color = palette.textTertiary ) } innerTextField() @@ -405,14 +431,17 @@ fun LocationChannelsSheet( Icon( imageVector = Icons.Filled.Map, contentDescription = stringResource(R.string.cd_open_map), - tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + tint = palette.textSecondary, + modifier = Modifier.size(18.dp) ) } val isValid = validateGeohash(normalized) - // iOS-style teleport button - Button( + // Compact teleport pill. Dims rather than disappearing when the + // geohash is incomplete so its position stays predictable. + val teleportColor = if (isValid) colorScheme.primary else palette.textTertiary + Surface( onClick = { if (isValid) { val level = levelForLength(normalized.length) @@ -426,25 +455,29 @@ fun LocationChannelsSheet( } }, enabled = isValid, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f), - contentColor = MaterialTheme.colorScheme.onSurface - ) + shape = RoundedCornerShape(8.dp), + color = palette.surfaceVariant ) { Row( + modifier = Modifier + .height(32.dp) + .padding(horizontal = 10.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { - Text( - text = stringResource(R.string.teleport), - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace - ) Icon( imageVector = Icons.Filled.PinDrop, contentDescription = stringResource(R.string.cd_teleport), - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurface + modifier = Modifier.size(12.dp), + tint = teleportColor + ) + Text( + text = stringResource(R.string.teleport).uppercase(), + fontSize = 11.sp, + letterSpacing = 0.8.sp, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily.Monospace, + color = teleportColor ) } } @@ -459,51 +492,103 @@ fun LocationChannelsSheet( text = customError!!, fontSize = 12.sp, fontFamily = FontFamily.Monospace, - color = Color.Red, + color = palette.accentRed, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp) + .padding(horizontal = SheetHorizontalPadding + 14.dp, vertical = 4.dp) ) } } + // Tor routing. Mirrors the toggle in About: this is where users actually + // think about IP exposure, because location channels are the feature that + // leaks it. + item(key = "tor_routing") { + val torProvider = remember { ArtiTorManager.getInstance() } + val torAvailable = remember { torProvider.isTorAvailable() } + var torMode by remember { mutableStateOf(TorPreferenceManager.get(context)) } + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) + .padding(top = 16.dp), + color = palette.surface, + shape = RoundedCornerShape(10.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 14.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = stringResource(R.string.location_tor_routing_title), + fontSize = 14.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = if (torAvailable) palette.textPrimary else palette.textTertiary + ) + BitchatBadge(text = stringResource(R.string.badge_recommended)) + } + Text( + text = stringResource(R.string.location_tor_routing_desc), + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = palette.textSecondary + ) + } + + Switch( + checked = torMode == TorMode.ON, + onCheckedChange = { enabled -> + if (torAvailable) { + torMode = if (enabled) TorMode.ON else TorMode.OFF + TorPreferenceManager.set(context, torMode) + } + }, + enabled = torAvailable, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = palette.accentGreen, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = palette.surfaceVariant + ) + ) + } + } + } + // Location services toggle button item(key = "location_toggle") { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(top = 8.dp) - ) { - Button( - onClick = { - if (locationServicesEnabled) { - locationManager.disableLocationServices() - } else { - locationManager.enableLocationServices() - } - }, - colors = ButtonDefaults.buttonColors( - containerColor = if (locationServicesEnabled) { - Color.Red.copy(alpha = 0.08f) - } else { - standardGreen.copy(alpha = 0.12f) - }, - contentColor = if (locationServicesEnabled) { - Color(0xFFBF1A1A) - } else { - standardGreen - } - ), - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = if (locationServicesEnabled) stringResource(R.string.disable_location_services) else stringResource(R.string.enable_location_services), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace - ) - } - } + SheetDestructiveButton( + text = if (locationServicesEnabled) { + stringResource(R.string.remove_location_access) + } else { + stringResource(R.string.enable_location_services) + }, + isDestructive = locationServicesEnabled, + onClick = { + if (locationServicesEnabled) { + locationManager.disableLocationServices() + } else { + locationManager.enableLocationServices() + } + }, + modifier = Modifier.padding( + start = SheetHorizontalPadding, + end = SheetHorizontalPadding, + top = 16.dp + ) + ) } } @@ -561,26 +646,41 @@ private fun ChannelRow( trailingContent: (@Composable (() -> Unit))? = null, onClick: () -> Unit ) { - // iOS-style list row (plain button, no card background) + val palette = LocalBitchatPalette.current + + // Selected rows step up to surfaceVariant rather than showing a trailing checkmark: with a + // leading status dot the fill alone communicates state and keeps the row's right edge free + // for the bookmark control. + val containerColor by animateColorAsState( + targetValue = if (isSelected) palette.surfaceVariant else palette.surface, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "channelRowContainer" + ) + Surface( onClick = onClick, - color = if (isSelected) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) - } else { - Color.Transparent - }, - shape = MaterialTheme.shapes.medium, + color = containerColor, + shape = RoundedCornerShape(10.dp), modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 2.dp) + .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), + .padding(horizontal = 14.dp, vertical = 10.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { + if (isSelected) { + Box( + modifier = Modifier + .padding(end = 10.dp) + .size(6.dp) + .background(palette.accentGreen, CircleShape) + ) + } + Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp) @@ -588,13 +688,16 @@ private fun ChannelRow( // Split title to handle count part with smaller font (iOS style) val (baseTitle, countSuffix) = splitTitleAndCount(title) - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { Text( text = baseTitle, - fontSize = BASE_FONT_SIZE.sp, + fontSize = 14.sp, fontFamily = FontFamily.Monospace, - fontWeight = if (titleBold) FontWeight.Bold else FontWeight.Normal, - color = titleColor ?: MaterialTheme.colorScheme.onSurface + fontWeight = if (titleBold) FontWeight.SemiBold else FontWeight.Medium, + color = titleColor ?: palette.textPrimary ) countSuffix?.let { count -> @@ -602,32 +705,21 @@ private fun ChannelRow( text = count, fontSize = 11.sp, fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + color = palette.textSecondary ) } } Text( text = subtitle, - fontSize = 12.sp, + fontSize = 11.sp, fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + color = palette.textSecondary ) } - Row(verticalAlignment = Alignment.CenterVertically) { - if (isSelected) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = stringResource(R.string.cd_selected), - tint = Color(0xFF32D74B), // iOS green for checkmark - modifier = Modifier.size(20.dp) - ) - } - - if (trailingContent != null) { - trailingContent() - } + if (trailingContent != null) { + trailingContent() } } } 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 6ad9bdd8..af1198bc 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -19,6 +19,7 @@ import com.bitchat.android.R import com.bitchat.android.geohash.ChannelID import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.nostr.LocationNotesManager +import com.bitchat.android.ui.theme.LocalBitchatPalette /** * Location Notes button component for MainHeader @@ -54,13 +55,13 @@ fun LocationNotesButton( val hasNotes = notesCount > 0 IconButton( onClick = onClick, - modifier = modifier.size(24.dp) + modifier = modifier.size(40.dp) ) { Icon( imageVector = Icons.Outlined.Description, // "long.text.page.and.pencil" equivalent contentDescription = stringResource(R.string.cd_location_notes), - modifier = Modifier.size(16.dp), - tint = if (hasNotes) colorScheme.primary else Color.Gray + modifier = Modifier.size(18.dp), + tint = if (hasNotes) colorScheme.primary else LocalBitchatPalette.current.textSecondary ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt index 4ca8674f..52632f93 100644 --- a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt +++ b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt @@ -81,6 +81,7 @@ fun MessageWithMatrixAnimation( onNicknameClick: ((String) -> Unit)?, onMessageLongPress: ((BitchatMessage) -> Unit)?, modifier: Modifier = Modifier, + showSender: Boolean = true, ) { AnimatedMessageDisplay( message = message, @@ -91,6 +92,7 @@ fun MessageWithMatrixAnimation( onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, modifier = modifier, + showSender = showSender, ) } @@ -107,6 +109,7 @@ private fun AnimatedMessageDisplay( onNicknameClick: ((String) -> Unit)?, onMessageLongPress: ((BitchatMessage) -> Unit)?, modifier: Modifier = Modifier, + showSender: Boolean = true, ) { var animatedContent by remember(message.id, message.content) { mutableStateOf(message.content) @@ -174,6 +177,7 @@ private fun AnimatedMessageDisplay( onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, modifier = modifier, + showSender = showSender, bodyContent = animatedContent, ) } 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 ec137437..71bfc62a 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* @@ -36,6 +37,7 @@ import com.bitchat.android.favorites.FavoritesPersistenceService import com.bitchat.android.geohash.ChannelID import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.LocalBitchatPalette import com.bitchat.android.nostr.GeohashAliasRegistry import com.bitchat.android.nostr.GeohashConversationRegistry import com.bitchat.android.services.ContactDirectory @@ -68,6 +70,8 @@ fun MeshPeerListSheet( 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 geohashPeopleCount = geohashPeople.size val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle() val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() } @@ -100,19 +104,33 @@ fun MeshPeerListSheet( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp) ) { + // Header: badge + live participant count + item(key = "people_header") { + val peopleCount = when (selectedLocationChannel) { + is ChannelID.Location -> geohashPeopleCount + else -> connectedPeers.count { it != viewModel.myPeerID } + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SheetHorizontalPadding + 14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + SheetHeaderBadge(icon = Icons.Filled.Groups) + Text( + text = stringResource(R.string.people_count_title, peopleCount), + fontSize = 20.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = colorScheme.primary + ) + } + } + // Channels section if (joinedChannels.isNotEmpty()) { item(key = "channels_header") { - Text( - text = stringResource(id = R.string.channels).uppercase(), - style = MaterialTheme.typography.labelLarge, - color = colorScheme.onSurface.copy(alpha = 0.7f), - fontWeight = FontWeight.Bold, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(top = 8.dp, bottom = 4.dp) - ) + SheetSectionLabel(text = stringResource(R.string.section_channels)) } items( @@ -222,22 +240,21 @@ private fun ChannelRow( onChannelClick: () -> Unit, onLeaveChannel: () -> Unit, ) { + val palette = LocalBitchatPalette.current + Surface( onClick = onChannelClick, - color = if (isSelected) { - colorScheme.primaryContainer.copy(alpha = 0.15f) - } else { - Color.Transparent - }, - shape = MaterialTheme.shapes.medium, + color = if (isSelected) palette.surfaceVariant else palette.surface, + shape = RoundedCornerShape(10.dp), modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 2.dp) + .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), + .heightIn(min = 44.dp) + .padding(start = 14.dp, end = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -253,15 +270,13 @@ private fun ChannelRow( colorScheme = colorScheme ) } - + Text( text = channel, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp - ), - color = if (isSelected) colorScheme.primary else colorScheme.onSurface, - fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + color = if (isSelected) colorScheme.primary else palette.textPrimary, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium ) } @@ -298,29 +313,20 @@ fun PeopleSection( SecureIdentityStateManager(context.applicationContext) } + val palette = LocalBitchatPalette.current + Column(modifier = modifier) { - Text( - text = stringResource(id = R.string.people).uppercase(), - style = MaterialTheme.typography.labelLarge, - color = colorScheme.onSurface.copy(alpha = 0.7f), - fontWeight = FontWeight.Bold, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(top = 8.dp, bottom = 4.dp) - ) + SheetSectionLabel(text = stringResource(R.string.section_mesh)) if (connectedPeers.isEmpty()) { Text( text = stringResource(id = R.string.no_one_connected), - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = 12.sp - ), - color = colorScheme.onSurface.copy(alpha = 0.5f), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = palette.textTertiary, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 40.dp, vertical = 12.dp) + .padding(horizontal = SheetHorizontalPadding + 14.dp, vertical = 12.dp) ) } @@ -547,26 +553,23 @@ private fun PeerItem( val isMe = displayName == "You" || peerID == currentNickname // Get consistent peer color (iOS-compatible) - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val assignedColor = viewModel.colorForMeshPeer(peerID, isDark) - val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor + val palette = LocalBitchatPalette.current + val assignedColor = viewModel.colorForMeshPeer(peerID, palette.isDark) + val baseColor = if (isMe) palette.accentOrange else assignedColor Surface( onClick = onItemClick, - color = if (isSelected) { - colorScheme.primaryContainer.copy(alpha = 0.15f) - } else { - Color.Transparent - }, - shape = MaterialTheme.shapes.medium, + color = if (isSelected) palette.surfaceVariant else palette.surface, + shape = RoundedCornerShape(10.dp), modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 2.dp) + .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), + .heightIn(min = 44.dp) + .padding(start = 14.dp, end = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -622,11 +625,9 @@ private fun PeerItem( // Base name with peer-specific color Text( text = baseName, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal - ), + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, color = baseColor, maxLines = 1, overflow = TextOverflow.Ellipsis @@ -636,11 +637,10 @@ private fun PeerItem( if (suffix.isNotEmpty()) { Text( text = suffix, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp - ), - color = baseColor.copy(alpha = 0.6f) + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, + color = baseColor.copy(alpha = SUFFIX_ALPHA) ) } 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 73fac04d..14f7b4f3 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -14,7 +14,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size 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.material.icons.Icons @@ -41,6 +41,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitchat.android.R @@ -50,6 +51,10 @@ import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.ui.media.FileMessageItem +import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.LocalBitchatPalette +import com.bitchat.android.ui.theme.MessageBodyTextStyle +import com.bitchat.android.ui.theme.MessageSenderTextStyle import java.text.SimpleDateFormat import java.util.Locale @@ -116,25 +121,42 @@ fun MessagesList( LazyColumn( state = listState, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + // Wider side gutters than the old 12.dp: the redesign trades a little line length for + // a much calmer edge, and long monospace lines were running into the screen bezel. + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp), + // Spacing is owned by each item so that a continuation of the same author can sit + // tighter than the start of a new speaker's run. + verticalArrangement = Arrangement.spacedBy(0.dp), modifier = modifier, reverseLayout = true ) { - items( - items = messages.asReversed(), - key = { it.id } - ) { message -> - MessageItem( - message = message, - messages = messages, - currentUserNickname = currentUserNickname, - meshService = meshService, - onNicknameClick = onNicknameClick, - onMessageLongPress = onMessageLongPress, - onCancelTransfer = onCancelTransfer, - onImageClick = onImageClick - ) + val reversed = messages.asReversed() + itemsIndexed( + items = reversed, + key = { _, message -> message.id } + ) { reversedIndex, message -> + // reverseLayout renders index 0 at the bottom, so the chronological predecessor of + // this row lives at a *higher* original index offset. Resolve against the original + // list rather than the reversed view to keep the grouping logic readable. + val originalIndex = messages.lastIndex - reversedIndex + val previous = messages.getOrNull(originalIndex - 1) + val isGrouped = MessageGrouping.shouldGroup(previous, message) + + MessageItem( + message = message, + messages = messages, + currentUserNickname = currentUserNickname, + meshService = meshService, + showSender = !isGrouped, + topSpacing = MessageGrouping.topSpacingFor( + isGrouped = isGrouped, + isFirstInList = originalIndex == 0 + ), + onNicknameClick = onNicknameClick, + onMessageLongPress = onMessageLongPress, + onCancelTransfer = onCancelTransfer, + onImageClick = onImageClick + ) } } } @@ -146,6 +168,8 @@ fun MessageItem( currentUserNickname: String, meshService: MeshService, messages: List = emptyList(), + showSender: Boolean = true, + topSpacing: Dp = 0.dp, onNicknameClick: ((String) -> Unit)? = null, onMessageLongPress: ((BitchatMessage) -> Unit)? = null, onCancelTransfer: ((BitchatMessage) -> Unit)? = null, @@ -155,7 +179,9 @@ fun MessageItem( val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) } Column( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(top = topSpacing), verticalArrangement = Arrangement.spacedBy(0.dp) ) { Box(modifier = Modifier.fillMaxWidth()) { @@ -174,6 +200,7 @@ fun MessageItem( meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, + showSender = showSender, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, @@ -211,12 +238,15 @@ fun MessageItem( meshService: MeshService, colorScheme: ColorScheme, timeFormatter: SimpleDateFormat, + showSender: Boolean, onNicknameClick: ((String) -> Unit)?, onMessageLongPress: ((BitchatMessage) -> Unit)?, onCancelTransfer: ((BitchatMessage) -> Unit)?, onImageClick: ((String, List, Int) -> Unit)?, modifier: Modifier = Modifier ) { + val palette = LocalBitchatPalette.current + // Image special rendering if (message.type == BitchatMessageType.Image) { com.bitchat.android.ui.media.ImageMessageItem( @@ -226,6 +256,7 @@ fun MessageItem( meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, + showSender = showSender, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, @@ -243,6 +274,7 @@ fun MessageItem( meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, + showSender = showSender, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, @@ -269,8 +301,9 @@ fun MessageItem( message = message, currentUserNickname = currentUserNickname, meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter + palette = palette, + timeFormatter = timeFormatter, + includeSender = showSender ) val haptic = LocalHapticFeedback.current AnnotatedClickableText( @@ -287,7 +320,7 @@ fun MessageItem( }, onLongPress = { onMessageLongPress?.invoke(message) }, fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface, + color = palette.textPrimary, ) // Try to load the file packet from the path @@ -363,19 +396,20 @@ fun MessageItem( meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, + showSender = showSender, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, modifier = modifier ) } else if (message.sender == "system") { - // Keep system messages on the compact legacy line. - val annotatedText = formatMessageAsAnnotatedString( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter - ) + // Background narration: `// Tor started. Routing all chats…` + val annotatedText = remember(message, palette) { + formatSystemMessage( + message = message, + palette = palette, + timeFormatter = timeFormatter + ) + } val haptic = LocalHapticFeedback.current Text( @@ -391,8 +425,10 @@ fun MessageItem( fontFamily = FontFamily.Monospace, softWrap = true, overflow = TextOverflow.Visible, - style = androidx.compose.ui.text.TextStyle( - color = colorScheme.onSurface + style = MessageBodyTextStyle.copy( + fontSize = (BASE_FONT_SIZE - 2).sp, + lineHeight = (BASE_FONT_SIZE + 3).sp, + color = palette.textSecondary ) ) } else { @@ -402,6 +438,7 @@ fun MessageItem( meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, + showSender = showSender, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, modifier = modifier, @@ -419,32 +456,30 @@ internal fun TextMessageLayout( onNicknameClick: ((String) -> Unit)?, onMessageLongPress: ((BitchatMessage) -> Unit)?, modifier: Modifier = Modifier, + showSender: Boolean = true, bodyContent: String = message.content, ) { + val palette = LocalBitchatPalette.current val myPeerId = meshService.myPeerID val displayMessage = remember(message, bodyContent) { if (bodyContent == message.content) message else message.copy(content = bodyContent) } - val senderText = remember(message, currentUserNickname, myPeerId, colorScheme) { + val senderText = remember(message, currentUserNickname, myPeerId, palette) { formatTextMessageSender( message = message, currentUserNickname = currentUserNickname, meshService = meshService, - colorScheme = colorScheme, + palette = palette, ) } - val metadataText = remember(message.timestamp, message.powDifficulty, timeFormatter) { - formatTextMessageMetadata( - message = message, - timeFormatter = timeFormatter, - ) - } - val bodyText = remember(displayMessage, currentUserNickname, myPeerId, colorScheme) { + // The timestamp trails the body rather than occupying its own column, so a short message + // no longer reserves a full-width row for eight grey characters. + val bodyText = remember(displayMessage, currentUserNickname, palette, timeFormatter) { formatTextMessageBody( message = displayMessage, currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, + palette = palette, + timeFormatter = timeFormatter, ) } val isSelf = message.isFromSelf(currentUserNickname, myPeerId) @@ -457,12 +492,9 @@ internal fun TextMessageLayout( Column( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(MessageGrouping.SENDER_TO_BODY_SPACING), ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { + if (showSender) { AnnotatedClickableText( text = senderText, annotationTags = listOf("nickname_click"), @@ -476,18 +508,11 @@ internal fun TextMessageLayout( } }, onLongPress = handleLongPress, - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxWidth(), fontFamily = FontFamily.Monospace, softWrap = false, overflow = TextOverflow.Ellipsis, - ) - AnnotatedClickableText( - text = metadataText, - annotationTags = emptyList(), - onAnnotationClick = { _, _ -> false }, - onLongPress = handleLongPress, - fontFamily = FontFamily.Monospace, - softWrap = false, + style = MessageSenderTextStyle, ) } @@ -515,7 +540,7 @@ internal fun TextMessageLayout( fontFamily = FontFamily.Monospace, softWrap = true, overflow = TextOverflow.Visible, - style = androidx.compose.ui.text.TextStyle(color = colorScheme.onSurface), + style = MessageBodyTextStyle.copy(color = palette.textPrimary), ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt b/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt new file mode 100644 index 00000000..f03e1b27 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt @@ -0,0 +1,79 @@ +package com.bitchat.android.ui + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.bitchat.android.model.BitchatMessage + +/** + * Consecutive-message grouping for the chat surface. + * + * The redesign suppresses the `@sender` label on runs of messages from the same author so the + * eye only has to register a name when the speaker actually changes. Grouped messages sit + * closer together; a new group gets extra breathing room above it. + */ +object MessageGrouping { + + /** Space above the first message of a group (i.e. one that renders its sender label). */ + val NEW_GROUP_SPACING: Dp = 10.dp + + /** Space above a continuation message inside an existing group. */ + val GROUPED_SPACING: Dp = 4.dp + + /** Gap between the sender label and the first line of the body. */ + val SENDER_TO_BODY_SPACING: Dp = 2.dp + + /** + * Messages further apart than this always start a new group, even from the same sender: + * a reply an hour later is a new thought, not a continuation. + */ + const val GROUPING_WINDOW_MS: Long = 5 * 60 * 1000L + + private const val SYSTEM_SENDER = "system" + + /** + * Whether [current] should be rendered as a continuation of [previous], hiding its sender + * label. + * + * [previous] is the message immediately *before* [current] in chronological order. Note the + * message list renders with `reverseLayout = true`, so callers must be careful to resolve + * the chronological predecessor rather than the visually preceding item. + */ + fun shouldGroup(previous: BitchatMessage?, current: BitchatMessage): Boolean { + if (previous == null) return false + + // System/action lines never participate in grouping in either direction: they are + // narration, and folding a real message into them would attribute it to "system". + if (previous.sender == SYSTEM_SENDER || current.sender == SYSTEM_SENDER) return false + + // Never group across the public/private boundary or between different channels. + if (previous.isPrivate != current.isPrivate) return false + if (previous.channel != current.channel) return false + + if (!isSameSender(previous, current)) return false + + val elapsed = current.timestamp.time - previous.timestamp.time + return elapsed in 0..GROUPING_WINDOW_MS + } + + /** + * Identity comparison. Peer IDs are authoritative when both messages carry one, because two + * different peers can share a nickname. Falls back to the display name (which includes the + * `#abcd` suffix) when peer IDs are unavailable, e.g. for locally injected messages. + */ + private fun isSameSender(previous: BitchatMessage, current: BitchatMessage): Boolean { + val previousPeerID = previous.senderPeerID + val currentPeerID = current.senderPeerID + return if (previousPeerID != null && currentPeerID != null) { + previousPeerID.equals(currentPeerID, ignoreCase = true) + } else { + previous.sender == current.sender + } + } + + /** Top padding for a message given whether it continues the previous author's run. */ + fun topSpacingFor(isGrouped: Boolean, isFirstInList: Boolean): Dp = when { + isFirstInList -> 0.dp + isGrouped -> GROUPED_SPACING + else -> NEW_GROUP_SPACING + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt index c61f2f9a..1c005b46 100644 --- a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt @@ -56,7 +56,7 @@ fun VoiceRecordButton( Box( modifier = modifier - .size(32.dp) + .size(36.dp) .background(backgroundColor, CircleShape) .pointerInput(Unit) { detectTapGestures( diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index 7c0a1ac6..5db41f17 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -21,6 +21,7 @@ import com.bitchat.android.core.ui.component.text.AnnotatedClickableText import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import androidx.compose.material3.ColorScheme +import com.bitchat.android.ui.theme.LocalBitchatPalette import java.text.SimpleDateFormat @Composable @@ -33,8 +34,10 @@ fun AudioMessageItem( onNicknameClick: ((String) -> Unit)?, onMessageLongPress: ((BitchatMessage) -> Unit)?, onCancelTransfer: ((BitchatMessage) -> Unit)?, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + showSender: Boolean = true ) { + val palette = LocalBitchatPalette.current val path = message.content.trim() // Derive sending progress if applicable val (overrideProgress, overrideColor) = when (val st = message.deliveryStatus) { @@ -51,8 +54,9 @@ fun AudioMessageItem( message = message, currentUserNickname = currentUserNickname, meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter + palette = palette, + timeFormatter = timeFormatter, + includeSender = showSender ) val haptic = LocalHapticFeedback.current AnnotatedClickableText( @@ -69,7 +73,7 @@ fun AudioMessageItem( }, onLongPress = { onMessageLongPress?.invoke(message) }, fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface, + color = palette.textPrimary, ) Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 35b0bbea..1691bb9b 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -27,6 +27,7 @@ import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType import androidx.compose.material3.ColorScheme import com.bitchat.android.core.ui.component.text.AnnotatedClickableText +import com.bitchat.android.ui.theme.LocalBitchatPalette import java.text.SimpleDateFormat @Composable @@ -41,16 +42,19 @@ fun ImageMessageItem( onMessageLongPress: ((BitchatMessage) -> Unit)?, onCancelTransfer: ((BitchatMessage) -> Unit)?, onImageClick: ((String, List, Int) -> Unit)?, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + showSender: Boolean = true ) { + val palette = LocalBitchatPalette.current val path = message.content.trim() Column(modifier = modifier.fillMaxWidth()) { val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString( message = message, currentUserNickname = currentUserNickname, meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter + palette = palette, + timeFormatter = timeFormatter, + includeSender = showSender ) val haptic = LocalHapticFeedback.current AnnotatedClickableText( @@ -67,7 +71,7 @@ fun ImageMessageItem( }, onLongPress = { onMessageLongPress?.invoke(message) }, fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface, + color = palette.textPrimary, ) val context = LocalContext.current diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt index eb1f5d86..172ad50d 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Camera import androidx.compose.material.icons.filled.Photo @@ -17,6 +18,7 @@ import androidx.compose.material3.Icon import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -24,6 +26,7 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import com.bitchat.android.features.media.ImageUtils +import com.bitchat.android.ui.theme.LocalBitchatPalette import java.io.File @OptIn(ExperimentalFoundationApi::class) @@ -88,7 +91,9 @@ fun ImagePickerButton( Box( modifier = modifier - .size(32.dp) + // 40.dp to match the header's tap targets and the composer's send button. + .size(40.dp) + .clip(CircleShape) .combinedClickable( onClick = { imagePicker.launch("image/*") }, onLongClick = { @@ -104,7 +109,7 @@ fun ImagePickerButton( Icon( imageVector = Icons.Filled.PhotoCamera, contentDescription = stringResource(com.bitchat.android.R.string.pick_image), - tint = Color.Gray, + tint = LocalBitchatPalette.current.textSecondary, modifier = Modifier.size(20.dp) ) } 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 new file mode 100644 index 00000000..bd4280eb --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt @@ -0,0 +1,106 @@ +package com.bitchat.android.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +/** + * Semantic design tokens for the bitchat redesign. + * + * Material3's [androidx.compose.material3.ColorScheme] cannot express what this app needs: + * `onSurface` is the terminal green used for chrome, while message bodies must render in a + * neutral near-white. Rather than repurposing M3 slots (which would repaint ~40 unrelated + * files unpredictably), the redesign exposes an explicit palette through + * [LocalBitchatPalette]. + * + * Consumers should prefer these tokens over ad-hoc hex literals so accents stay consistent + * and light/dark pairs are defined in exactly one place. + */ +@Immutable +data class BitchatPalette( + /** Authoritative dark-mode flag. Prefer this over summing background channels. */ + val isDark: Boolean, + + // MARK: - Surfaces + /** Screen, header and composer background. Slightly green-tinted near-black. */ + val background: Color, + /** Sheets, cards and list rows: one step above [background]. */ + val surface: Color, + /** Composer pill fill, chips, selected rows: one step above [surface]. */ + val surfaceVariant: Color, + /** Borders, drawn at full opacity (no `.copy(alpha = ...)` needed). */ + val outline: Color, + /** Hairline dividers. */ + val outlineVariant: Color, + + // MARK: - Text + /** Message bodies and row titles. Neutral, not green. */ + val textPrimary: Color, + /** Subtitles, secondary metadata, system/action messages. */ + val textSecondary: Color, + /** Timestamps, placeholders, section labels, disabled states. */ + val textTertiary: Color, + + // MARK: - Accents + /** Geohash channels, bookmark-on, connected/OK states. */ + val accentGreen: Color, + /** Mesh channel, links, geohash references. */ + val accentBlue: Color, + /** Self, mentions targeting you, unread DMs. */ + val accentOrange: Color, + /** Errors, warning card, destructive actions. */ + val accentRed: Color, + /** Nostr reachability. */ + val accentPurple: Color, +) + +val DarkBitchatPalette = BitchatPalette( + isDark = true, + background = Color(0xFF070D07), + surface = Color(0xFF0E150E), + surfaceVariant = Color(0xFF182118), + outline = Color(0xFF2A3A2A), + outlineVariant = Color(0xFF1C271C), + textPrimary = Color(0xFFE8EDE8), + textSecondary = Color(0xFF9AA69A), + textTertiary = Color(0xFF6B776B), + accentGreen = Color(0xFF32D74B), + accentBlue = Color(0xFF0A84FF), + accentOrange = Color(0xFFFF9F0A), + accentRed = Color(0xFFFF453A), + accentPurple = Color(0xFFBF5AF2), +) + +val LightBitchatPalette = BitchatPalette( + isDark = false, + background = Color(0xFFFBFDFB), + surface = Color(0xFFF2F6F2), + surfaceVariant = Color(0xFFE7EDE7), + outline = Color(0xFFCBD6CB), + outlineVariant = Color(0xFFDEE6DE), + textPrimary = Color(0xFF131A13), + textSecondary = Color(0xFF4C574C), + textTertiary = Color(0xFF757F75), + accentGreen = Color(0xFF248A3D), + accentBlue = Color(0xFF007AFF), + accentOrange = Color(0xFFFF9500), + accentRed = Color(0xFFD70015), + accentPurple = Color(0xFFAF52DE), +) + +val LocalBitchatPalette = staticCompositionLocalOf { DarkBitchatPalette } + +/** + * Motion tokens. The redesign leans on short, snappy transitions: long durations read as + * sluggish on a chat surface where the user is scanning quickly. + */ +object BitchatMotion { + /** Icon tints, text colors, small fills. */ + const val QUICK_MS = 120 + + /** Tab indicators, pill growth, chip reveals. */ + const val STANDARD_MS = 180 + + /** Sheet-level fades and scroll-driven top bars. */ + const val EMPHASIZED_MS = 240 +} 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 76ef3130..09d03959 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 @@ -9,6 +9,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -16,17 +17,23 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalView -// Colors that match the iOS bitchat theme +// Colors that match the iOS bitchat theme. +// Surface/outline/error slots are derived from BitchatPalette so that screens which have not +// yet migrated to the explicit palette still pick up the redesigned tinted surfaces. private val DarkColorScheme = darkColorScheme( primary = Color(0xFF39FF14), // Bright green (terminal-like) onPrimary = Color.Black, secondary = Color(0xFF2ECB10), // Darker green onSecondary = Color.Black, - background = Color.Black, - onBackground = Color(0xFF39FF14), // Green on black - surface = Color(0xFF111111), // Very dark gray + background = DarkBitchatPalette.background, + onBackground = Color(0xFF39FF14), // Green on near-black + surface = DarkBitchatPalette.surface, onSurface = Color(0xFF39FF14), // Green text - error = Color(0xFFFF5555), // Red for errors + surfaceVariant = DarkBitchatPalette.surfaceVariant, + onSurfaceVariant = DarkBitchatPalette.textSecondary, + outline = DarkBitchatPalette.outline, + outlineVariant = DarkBitchatPalette.outlineVariant, + error = DarkBitchatPalette.accentRed, onError = Color.Black ) @@ -35,11 +42,15 @@ private val LightColorScheme = lightColorScheme( onPrimary = Color.White, secondary = Color(0xFF006600), // Even darker green onSecondary = Color.White, - background = Color.White, - onBackground = Color(0xFF008000), // Dark green on white - surface = Color(0xFFF8F8F8), // Very light gray + background = LightBitchatPalette.background, + onBackground = Color(0xFF008000), // Dark green on off-white + surface = LightBitchatPalette.surface, onSurface = Color(0xFF008000), // Dark green text - error = Color(0xFFCC0000), // Dark red for errors + surfaceVariant = LightBitchatPalette.surfaceVariant, + onSurfaceVariant = LightBitchatPalette.textSecondary, + outline = LightBitchatPalette.outline, + outlineVariant = LightBitchatPalette.outlineVariant, + error = LightBitchatPalette.accentRed, onError = Color.White ) @@ -61,6 +72,7 @@ fun BitchatTheme( } val colorScheme = if (shouldUseDark) DarkColorScheme else LightColorScheme + val palette = if (shouldUseDark) DarkBitchatPalette else LightBitchatPalette val view = LocalView.current SideEffect { @@ -83,9 +95,11 @@ fun BitchatTheme( } } - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) + CompositionLocalProvider(LocalBitchatPalette provides palette) { + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) + } } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt b/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt index a8c68a4b..66236747 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt @@ -9,6 +9,26 @@ import androidx.compose.ui.unit.sp // Base font size for consistent scaling across the app internal const val BASE_FONT_SIZE = com.bitchat.android.util.AppConstants.UI.BASE_FONT_SIZE_SP // sp - increased from 14sp for better readability +/** + * Message body style. The generous leading (1.4x) is what gives the redesigned chat surface + * its readable rhythm; without an explicit lineHeight, Compose falls back to the font's own + * metrics and lines sit too tightly for long paragraphs. + */ +val MessageBodyTextStyle = TextStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Normal, + fontSize = BASE_FONT_SIZE.sp, + lineHeight = (BASE_FONT_SIZE + 6).sp +) + +/** Sender label above a message group. Single line, never wraps. */ +val MessageSenderTextStyle = TextStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + fontSize = BASE_FONT_SIZE.sp, + lineHeight = (BASE_FONT_SIZE + 5).sp +) + // Typography matching the iOS monospace design - using BASE_FONT_SIZE for consistency val Typography = Typography( bodyLarge = TextStyle( @@ -21,7 +41,7 @@ val Typography = Typography( fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Normal, fontSize = BASE_FONT_SIZE.sp, - lineHeight = (BASE_FONT_SIZE + 3).sp + lineHeight = (BASE_FONT_SIZE + 6).sp ), bodySmall = TextStyle( fontFamily = FontFamily.Monospace, @@ -35,12 +55,31 @@ val Typography = Typography( fontSize = (BASE_FONT_SIZE + 3).sp, lineHeight = (BASE_FONT_SIZE + 9).sp ), + // Previously unset, which leaked the Roboto default into onboarding + sheet titles. + headlineLarge = TextStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = (BASE_FONT_SIZE + 13).sp, + lineHeight = (BASE_FONT_SIZE + 21).sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + fontSize = (BASE_FONT_SIZE + 5).sp, + lineHeight = (BASE_FONT_SIZE + 13).sp + ), titleMedium = TextStyle( fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium, fontSize = (BASE_FONT_SIZE + 1).sp, lineHeight = (BASE_FONT_SIZE + 7).sp ), + labelLarge = TextStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + fontSize = (BASE_FONT_SIZE - 1).sp, + lineHeight = (BASE_FONT_SIZE + 5).sp + ), labelMedium = TextStyle( fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 4d2ffec0..bd8949cb 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -96,7 +96,6 @@ اختيار صورة - دردشة شبكية لامركزية مع تشفير طرف إلى طرف دردشة شبكية دون اتصال تواصل مباشرة عبر Bluetooth LE بدون إنترنت أو خوادم. تُعاد توجيه الرسائل عبر الأجهزة القريبة لتوسيع النطاق. قنوات geohash عبر الإنترنت diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index c5902ee2..f978e0ce 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -96,7 +96,6 @@ ছবি নির্বাচন করুন - এন্ড-টু-এন্ড এনক্রিপশন সহ বিকেন্দ্রীভূত মেশ চ্যাট অফলাইন মেশ চ্যাট ইন্টারনেট বা সার্ভার ছাড়াই Bluetooth LE এর মাধ্যমে সরাসরি যোগাযোগ করুন। পরিসর বাড়ানোর জন্য কাছাকাছি ডিভাইসের মাধ্যমে বার্তা রিলে করা হয়। অনলাইন জিওহ্যাশ চ্যানেল diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9002f9bd..9fe6301e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -96,7 +96,6 @@ Bild auswählen - dezentraler Mesh‑Chat mit Ende‑zu‑Ende‑Verschlüsselung Offline‑Mesh‑Chat Kommuniziere direkt über Bluetooth LE ohne Internet oder Server. Nachrichten werden über nahe Geräte weitergeleitet, um die Reichweite zu erhöhen. Online‑Geohash‑Kanäle diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index cf6b336e..89d907da 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -96,7 +96,6 @@ Elegir imagen - chat mesh descentralizado con cifrado de extremo a extremo Chat mesh sin conexión Comunícate directamente a través de Bluetooth LE sin internet o servidores. Los mensajes se retransmiten a través de dispositivos cercanos para extender el alcance. Canales geohash en línea diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 84273529..5f895797 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -96,7 +96,6 @@ انتخاب تصویر - گفت‌وگوی مِش غیرمتمرکز با رمزگذاری سرتاسری گفت‌وگوی مِش آفلاین بدون اینترنت یا سرور، مستقیماً از طریق Bluetooth LE ارتباط برقرار کنید. پیام‌ها از طریق دستگاه‌های نزدیک رله می‌شوند تا بُرد افزایش یابد. کانال‌های geohash آنلاین diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 65e4cbd2..dc231763 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -96,7 +96,6 @@ Pumili ng larawan - desentralisadong mesh messaging na may end‑to‑end encryption Offline Mesh Chat Makipag‑usap direkta sa Bluetooth LE nang walang internet o server. Ang mga mensahe ay nire‑relay ng mga kalapit na device. Online na Geohash na mga Channel diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index aee12bf1..9d2b515f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -96,7 +96,6 @@ Choisir une image - messagerie mesh décentralisée avec chiffrement de bout en bout Chat Mesh hors‑ligne Communique directement via Bluetooth LE sans internet ni serveurs. Les messages sont relayés par les appareils proches pour étendre la portée. Canaux Geohash en ligne diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 926cc3a4..3ec6ac27 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -96,7 +96,6 @@ छवि चुनें - विकेन्द्रीकृत मेष चैट • एंड‑टू‑एंड एन्क्रिप्शन ऑफ़लाइन मेष चैट इंटरनेट या सर्वर के बिना Bluetooth LE के माध्यम से सीधे संवाद करें। संदेशों को पास के उपकरणों के माध्यम से रिले किया जाता है ताकि पहुँच बढ़े। ऑनलाइन geohash चैनल diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index bb15e3bb..048d40b2 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -96,7 +96,6 @@ Pilih gambar - chat mesh terdesentralisasi dengan enkripsi end-to-end Chat Mesh Offline Berkomunikasi langsung melalui Bluetooth LE tanpa Internet atau server. Pesan diteruskan melalui perangkat terdekat untuk memperluas jangkauan. Channel Geohash Online diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c10a8575..434f1dc6 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -96,7 +96,6 @@ Scegli immagine - messaggistica mesh decentralizzata con crittografia end-to-end Chat mesh offline Comunica direttamente tramite Bluetooth LE senza internet o server. I messaggi vengono inoltrati attraverso i dispositivi vicini per estendere la portata. Canali geohash online diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index eb9d6ba1..1a3df392 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -96,7 +96,6 @@ 画像を選択 - 分散型メッシュチャット・エンドツーエンド暗号化 オフライン・メッシュチャット インターネットやサーバーなしで、Bluetooth LE 経由で直接通信します。近隣のデバイスを経由してメッセージを中継し、到達範囲を広げます。 オンライン Geohash チャンネル diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index 1e3187d0..cf08947b 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -96,7 +96,6 @@ სურათის არჩევა - დეცენტრალიზებული mesh-ჩათი ბოლო-მდე დაშიფვრით ოფლაინ mesh-ჩათი ესაუბრეთ პირდაპირ Bluetooth LE-ის საშუალებით ინტერნეტის ან სერვერების გარეშე. შეტყობინებები გადაიგზავნება ახლომდებარე მოწყობილობებზე, რათა გაიზარდოს დაფარვა. ონლაინ geohash არხები diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 829cd1f5..5be26395 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -96,7 +96,6 @@ 이미지 선택 - 종단간 암호화를 갖춘 탈중앙 메쉬 채팅 오프라인 메쉬 채팅 인터넷이나 서버 없이 Bluetooth LE로 직접 소통합니다. 범위를 확장하기 위해 근처 기기를 통해 메시지가 중계됩니다. 온라인 geohash 채널 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index a7edd747..68b640f9 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -96,7 +96,6 @@ Hifidy sary - hafatra mesh tsy misy foibe misy fandokoana farany amin\'ny farany Mesh Chat tsy an-tserasera Mifandraisa mivantana amin\'ny alalan\'ny Bluetooth LE tsy misy Internet na mpizara. Ny hafatra dia mamindra amin\'ny alalan\'ny fitaovana akaiky mba hanitarana ny elanelana. Fantsona Geohash an-tserasera diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index f1dbb3c6..d460c037 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -96,7 +96,6 @@ तस्वीर छान्नुहोस् - विकेन्द्रित मेष सन्देश अन्त्यदेखि अन्त्य इन्क्रिप्सनसहित अफलाइन मेष च्याट इन्टरनेट वा सर्भर बिना सिधै Bluetooth LE मार्फत सञ्चार गर्नुहोस्। नजिकका यन्त्रहरूबाट सन्देश रिल레이 हुन्छ। अनलाइन Geohash च्यानलहरू diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 6a014f87..4cf60a3d 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -96,7 +96,6 @@ Afbeelding kiezen - gedecentraliseerde meshcommunicatie met end-to-end-versleuteling Offline meshchat Communiceer rechtstreeks via Bluetooth LE zonder internet of servers. Berichten worden doorgestuurd via apparaten in de buurt om het bereik te vergroten. Online geohash-kanalen diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index 3b94bd8c..553e35ca 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -96,7 +96,6 @@ تصویر چُنو - اینڈ ٹو اینڈ انکرپشن نال غیر مرکزی میش چیٹ آف لائن میش چیٹ انٹرنیٹ یا سرور توں بغیر Bluetooth LE راہین سیدھا رابطہ کرو۔ رینج ودھاؤن لئی پیغام نیڑے ڈیوائساں راہین ریلے ہوندے نیں۔ آن لائن geohash چینل diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 45f41512..86e238cb 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -96,7 +96,6 @@ Escolher imagem - chat mesh descentralizado com criptografia de ponta a ponta Chat mesh offline Comunique diretamente via Bluetooth LE sem internet ou servidores. As mensagens são retransmitidas por dispositivos próximos para ampliar o alcance. Canais geohash online diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 274bbaae..2aeb5352 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -96,7 +96,6 @@ Escolher imagem - chat mesh descentralizado com encriptação ponto a ponto Chat mesh offline Comunique diretamente via Bluetooth LE sem internet ou servidores. As mensagens são retransmitidas através de dispositivos próximos para estender o alcance. Canais geohash online diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d1d27acd..76b332ff 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -86,7 +86,6 @@ ❌ Закрыть Выбрать изображение - децентрализованный mesh‑чат со сквозным шифрованием Оффлайн Mesh‑чат Общение напрямую по Bluetooth LE без интернета и серверов. Сообщения ретранслируются близлежащими устройствами. Онлайн‑каналы Geohash diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index aa3194ed..e465e4da 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -86,7 +86,6 @@ ❌ Stäng Välj bild - decentraliserad mesh‑meddelanden med ända‑till‑ända‑kryptering Offline Mesh‑chatt Kommunicera direkt via Bluetooth LE utan internet eller servrar. Meddelanden reläas av enheter i närheten. Online Geohash‑kanaler diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 5b2ba927..33d45309 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -96,7 +96,6 @@ เลือกภาพ - แชทเมชแบบกระจายศูนย์พร้อมการเข้ารหัสแบบปลายทางถึงปลายทาง แชทเมชออฟไลน์ สื่อสารโดยตรงผ่าน Bluetooth LE โดยไม่ใช้อินเทอร์เน็ตหรือเซิร์ฟเวอร์ ข้อความจะถูกส่งต่อผ่านอุปกรณ์ใกล้เคียงเพื่อขยายระยะ ช่อง geohash ออนไลน์ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index d27d1b59..0eccd05f 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -86,7 +86,6 @@ ❌ Kapat Görsel seç - uçtan uca şifrelemeli merkezsiz mesh mesajlaşma Çevrimdışı Mesh Sohbeti İnternet/ sunucu olmadan doğrudan Bluetooth LE ile iletişim kur. Mesajlar yakındaki cihazlarca aktarılır. Çevrimiçi Geohash Kanalları diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 1d8399de..df6d1f4f 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -96,7 +96,6 @@ تصویر منتخب کریں - غیر مرکزی میش چیٹ • اینڈ‑ٹو‑اینڈ انکرپشن آف لائن میش چیٹ انٹرنیٹ یا سرورز کے بغیر Bluetooth LE کے ذریعے براہ راست بات چیت کریں۔ پیغامات قریبی آلات کے ذریعے ریلے ہوتے ہیں تاکہ رسائی بڑھے۔ آن لائن geohash چینلز diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 6ab72813..b5cd2a6e 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -96,7 +96,6 @@ Chọn hình ảnh - trò chuyện mesh phi tập trung với mã hóa đầu cuối Trò chuyện Mesh ngoại tuyến Liên lạc trực tiếp qua Bluetooth LE mà không cần Internet hoặc máy chủ. Tin nhắn được chuyển tiếp qua các thiết bị gần để mở rộng phạm vi. Kênh Geohash trực tuyến diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 8a28e5f7..05143855 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -96,7 +96,6 @@ 选择图片 - 去中心化 mesh 消息,端到端加密 离线 Mesh 聊天 无需网络或服务器,通过 Bluetooth LE 直接通信。利用附近设备中继消息以扩展范围。 在线 Geohash 频道 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e674112c..e20a4b49 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -109,7 +109,50 @@ Pick file - decentralized mesh messaging with end-to-end encryption + The sidegroup chat + + + How to use + Basic info + + + How To Use + Set your nickname by tapping it + Tap the channel name to change channels + Tap the people icon to see who\'s in the channel + Bookmark a channel to keep it in your list + Type @ to mention someone + Type / to see the available commands + Triple-tap the logo to wipe all data + + + About + Theme + Settings + + + Offline Communication + Chat over a Bluetooth mesh with no internet and no servers. Messages relay through nearby devices to extend range. + Local Channels + Geohash channels connect you with people nearby, extending the mesh over public relays. + No Tracking + No servers, accounts, or data collection. + Ephemeral Identity + A new peer ID is generated regularly. + Panic Mode + Triple-tap the logo to instantly clear all data. + + + Warning + Private message security has not yet been fully audited. Do not use for critical situations until this warning disappears. + + + Tor Network + Difficulty + %1$d bits • %2$s + Connected (%1$d%%) + Disconnected + Offline Mesh Chat Communicate directly via Bluetooth LE without internet or servers. Messages relay through nearby devices to extend range. Online Geohash Channels @@ -213,7 +256,24 @@ #location channels + Location Channels chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. do not screenshot or share this screen to protect your privacy. + + + Bookmarked + Nearby + Tor Routing + Hides your IP for location channels. + Recommended + Remove location access + + + People (%1$d) + On location + Teleported in + Channels + Mesh + grant location permission location permission denied. enable in settings to use location channels. @@ -378,7 +438,7 @@ (you) pan and zoom to select a geohash select - type a message... + Type a message… @%1$s mention %1$d / %2$d 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 304166c0..44072412 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -1,10 +1,16 @@ package com.bitchat.android.ui +import androidx.compose.ui.text.SpanStyle import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.theme.DarkBitchatPalette +import com.bitchat.android.ui.theme.LightBitchatPalette import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test class ChatUIUtilsTest { @@ -12,33 +18,298 @@ class ChatUIUtilsTest { timeZone = java.util.TimeZone.getTimeZone("UTC") } + private val palette = DarkBitchatPalette + + private fun message( + content: String, + sender: String = "alice", + powDifficulty: Int? = null, + ) = BitchatMessage( + sender = sender, + content = content, + timestamp = Date(0), + powDifficulty = powDifficulty, + ) + + // MARK: - Timestamp metadata + @Test fun `text message metadata separates PoW badge with one space`() { - val message = BitchatMessage( - sender = "alice", - content = "hello", - timestamp = Date(0), - powDifficulty = 12, - ) - assertEquals( "00:00:00 ⛨12b", - formatTextMessageMetadata(message, timeFormatter).text, + formatTextMessageMetadata(message("hello", powDifficulty = 12), timeFormatter).text, ) } @Test fun `text message metadata omits non-positive PoW difficulty`() { - val message = BitchatMessage( - sender = "alice", - content = "hello", - timestamp = Date(0), - powDifficulty = 0, - ) - assertEquals( "00:00:00", - formatTextMessageMetadata(message, timeFormatter).text, + formatTextMessageMetadata(message("hello", powDifficulty = 0), timeFormatter).text, ) } + + // MARK: - Body with inline trailing timestamp + + @Test + fun `body appends timestamp inline after the message text`() { + val body = formatTextMessageBody( + message = message("hello there"), + currentUserNickname = "bob", + palette = palette, + timeFormatter = timeFormatter, + ) + + assertEquals("hello there 00:00:00", body.text) + } + + @Test + fun `body appends PoW badge after the inline timestamp`() { + val body = formatTextMessageBody( + message = message("mined", powDifficulty = 8), + currentUserNickname = "bob", + palette = palette, + timeFormatter = timeFormatter, + ) + + assertEquals("mined 00:00:00 ⛨8b", body.text) + } + + @Test + fun `body can omit the timestamp for callers that render it separately`() { + val body = formatTextMessageBody( + message = message("hello there"), + currentUserNickname = "bob", + palette = palette, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + assertEquals("hello there", body.text) + } + + @Test + fun `body renders plain text in the neutral palette color, not terminal green`() { + val body = formatTextMessageBody( + message = message("plain words"), + currentUserNickname = "bob", + palette = palette, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + val textStyle = body.spanStyles.first { it.start == 0 } + assertEquals(palette.textPrimary, textStyle.item.color) + } + + @Test + fun `body does not bold plain text for the sender's own messages`() { + // Regression guard: the old renderer bolded the entire body when the message was yours, + // and again when you were mentioned, which is what made busy channels unreadable. + val body = formatTextMessageBody( + message = message("my own words", sender = "bob"), + currentUserNickname = "bob", + palette = palette, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + assertTrue( + "no span should force a bold weight", + body.spanStyles.none { it.item.fontWeight?.weight?.let { w -> w >= 700 } == true } + ) + } + + // MARK: - Mention chips + + private fun mentionChipSpans(body: androidx.compose.ui.text.AnnotatedString) = + body.spanStyles.filter { it.item.background.isSpecified() } + + private fun androidx.compose.ui.graphics.Color.isSpecified() = + this != androidx.compose.ui.graphics.Color.Unspecified && alpha > 0f + + @Test + fun `mention renders as a single contiguous background chip`() { + val body = formatTextMessageBody( + message = message("hey @carol#04af what's up"), + currentUserNickname = "bob", + palette = palette, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + val chips = mentionChipSpans(body) + assertEquals("expected exactly one chip", 1, chips.size) + + // The chip must cover "@carol#04af" as one run so it paints without seams. + val chip = chips.single() + assertEquals("@carol#04af", body.text.substring(chip.start, chip.end)) + } + + @Test + fun `mention targeting the current user uses the orange accent`() { + val body = formatTextMessageBody( + message = message("ping @bob now"), + currentUserNickname = "bob", + palette = palette, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + val chip = mentionChipSpans(body).single() + assertEquals(palette.accentOrange.copy(alpha = MENTION_CHIP_ALPHA_SELF), chip.item.background) + + val nameStyle: SpanStyle = body.spanStyles + .first { it.start == chip.start && it.item.color == palette.accentOrange } + .item + assertEquals(palette.accentOrange, nameStyle.color) + } + + @Test + fun `mention of another user is tinted by that user's own peer color`() { + val body = formatTextMessageBody( + message = message("cc @carol#04af"), + currentUserNickname = "bob", + palette = palette, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + val expected = colorForPeerSeed("carol#04af", palette.isDark) + val chip = mentionChipSpans(body).single() + assertEquals(expected.copy(alpha = MENTION_CHIP_ALPHA), chip.item.background) + } + + @Test + fun `message without mentions has no background chips`() { + val body = formatTextMessageBody( + message = message("no mentions in here"), + currentUserNickname = "bob", + palette = palette, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + assertTrue(mentionChipSpans(body).isEmpty()) + } + + // MARK: - System / action messages + + @Test + fun `system message uses a double-slash prefix and no brackets`() { + val text = formatSystemMessage( + message = message("Tor started. Routing all chats via Tor", sender = "system"), + palette = palette, + timeFormatter = timeFormatter, + ).text + + assertEquals("// Tor started. Routing all chats via Tor 00:00:00", text) + } + + @Test + fun `system message is not italic`() { + // The old treatment was `* italic asterisks *`, which competed visually with real + // messages despite being lower-priority narration. + val annotated = formatSystemMessage( + message = message("tor restarting", sender = "system"), + palette = palette, + timeFormatter = timeFormatter, + ) + + assertTrue(annotated.spanStyles.all { it.item.fontStyle == null }) + } + + // MARK: - Sender label + + @Test + fun `sender label drops angle brackets and dims the hash suffix`() { + val sender = formatTextMessageSender( + message = message("hi", sender = "carol#04af"), + currentUserNickname = "bob", + meshService = FakeMeshServiceIds.stub(), + palette = palette, + ) + + assertEquals("@carol#04af", sender.text) + + val suffixSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "#04af" } + val nameSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "@carol" } + assertNotNull(suffixSpan.item.color) + assertTrue( + "suffix must be dimmer than the name", + suffixSpan.item.color.alpha < nameSpan.item.color.alpha + ) + } + + @Test + fun `sender label annotates the nickname for others but not for yourself`() { + val other = formatTextMessageSender( + message = message("hi", sender = "carol#04af"), + currentUserNickname = "bob", + meshService = FakeMeshServiceIds.stub(), + palette = palette, + ) + assertEquals(1, other.getStringAnnotations("nickname_click", 0, other.length).size) + + val mine = formatTextMessageSender( + message = message("hi", sender = "bob"), + currentUserNickname = "bob", + meshService = FakeMeshServiceIds.stub(), + palette = palette, + ) + assertTrue(mine.getStringAnnotations("nickname_click", 0, mine.length).isEmpty()) + } + + // MARK: - Peer colors + + @Test + fun `peer color hue is stable across light and dark, only chroma differs`() { + // Hue derivation must stay byte-identical to iOS; only saturation/value are tuned for + // the redesigned neutral message body. + val dark = colorForPeerSeed("noise:abc", isDark = true) + val light = colorForPeerSeed("noise:abc", isDark = false) + + val darkHsv = FloatArray(3) + val lightHsv = FloatArray(3) + rgbToHsv(dark.red, dark.green, dark.blue, darkHsv) + rgbToHsv(light.red, light.green, light.blue, lightHsv) + + assertEquals(darkHsv[0].toDouble(), lightHsv[0].toDouble(), 1.0) + } + + @Test + fun `peer color avoids the orange hue reserved for self`() { + // Sweep a range of seeds; none may land within the reserved orange band. + repeat(500) { i -> + val color = colorForPeerSeed("noise:seed$i", isDark = true) + val hsv = FloatArray(3) + rgbToHsv(color.red, color.green, color.blue, hsv) + val distanceFromOrange = kotlin.math.abs(hsv[0] - 30f) + assertTrue( + "seed$i resolved to ${hsv[0]}°, inside the reserved orange band", + distanceFromOrange >= 17f || hsv[1] < 0.01f + ) + } + } + + @Test + fun `light palette is not the dark palette`() { + assertNull(null) + assertTrue(LightBitchatPalette.textPrimary != DarkBitchatPalette.textPrimary) + assertTrue(LightBitchatPalette.isDark != DarkBitchatPalette.isDark) + } + + private fun rgbToHsv(r: Float, g: Float, b: Float, out: FloatArray) { + val max = maxOf(r, g, b) + val min = minOf(r, g, b) + val delta = max - min + out[0] = when { + delta == 0f -> 0f + max == r -> (60f * (((g - b) / delta) % 6f) + 360f) % 360f + max == g -> 60f * (((b - r) / delta) + 2f) + else -> 60f * (((r - g) / delta) + 4f) + } + out[1] = if (max == 0f) 0f else delta / max + out[2] = max + } }