From 25e9779f7357edc7b2224fd92b4eafdbb3ed14d0 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:06:22 +0200 Subject: [PATCH] colors --- .../core/ui/component/button/CloseButton.kt | 6 +- .../com/bitchat/android/ui/AboutSections.kt | 36 +++--- .../java/com/bitchat/android/ui/AboutSheet.kt | 48 ++++---- .../java/com/bitchat/android/ui/ChatHeader.kt | 24 ++-- .../java/com/bitchat/android/ui/ChatScreen.kt | 17 +-- .../com/bitchat/android/ui/ChatUIUtils.kt | 105 +++++------------- .../com/bitchat/android/ui/ChatUserSheet.kt | 14 +-- .../com/bitchat/android/ui/ChatViewModel.kt | 11 +- .../bitchat/android/ui/GeohashPeopleList.kt | 13 ++- .../android/ui/GeohashPickerActivity.kt | 8 +- .../bitchat/android/ui/GeohashViewModel.kt | 6 +- .../com/bitchat/android/ui/InputComponents.kt | 35 +++--- .../com/bitchat/android/ui/LinkPreviewPill.kt | 11 +- .../android/ui/LocationChannelsSheet.kt | 90 +++++++++------ .../bitchat/android/ui/LocationNotesButton.kt | 4 +- .../bitchat/android/ui/LocationNotesSheet.kt | 12 +- .../bitchat/android/ui/MeshPeerListSheet.kt | 25 +++-- .../bitchat/android/ui/MessageComponents.kt | 31 ++++-- .../com/bitchat/android/ui/PeerColorSeed.kt | 33 ++++++ .../android/ui/SecurityVerificationSheet.kt | 7 +- .../bitchat/android/ui/VerificationSheet.kt | 4 +- .../android/ui/media/AudioMessageItem.kt | 3 +- .../android/ui/media/ImageMessageItem.kt | 3 +- .../android/ui/theme/BitchatPalette.kt | 81 +++----------- .../bitchat/android/ui/theme/PeerColors.kt | 30 +++++ .../com/bitchat/android/ui/theme/Theme.kt | 67 ++++++----- .../com/bitchat/android/ui/ChatUIUtilsTest.kt | 72 +++++++++--- 27 files changed, 415 insertions(+), 381 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt index 091f93fc..9c60123f 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt @@ -6,26 +6,26 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.bitchat.android.R -import com.bitchat.android.ui.theme.LocalBitchatPalette @Composable fun CloseButton( onClick: () -> Unit, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme IconButton( onClick = onClick, // 44.dp to match every other tap target in the app's chrome. modifier = modifier.size(44.dp), colors = IconButtonDefaults.iconButtonColors( - contentColor = palette.accentGreen, + contentColor = colorScheme.primary, containerColor = Color.Transparent ) ) { diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt index e6da1eee..e36ee7be 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -120,7 +120,6 @@ internal fun SheetIconSectionHeader( modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current Column( modifier = modifier @@ -152,7 +151,7 @@ internal fun SheetIconSectionHeader( fontSize = 12.sp, lineHeight = 17.sp, fontFamily = BitchatFontFamily, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) } } @@ -161,11 +160,11 @@ internal fun SheetIconSectionHeader( /** Inset divider used inside grouped sheet cards (aligns with text column after the leading slot). */ @Composable internal fun SheetCardDivider() { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme HorizontalDivider( modifier = Modifier.padding(start = SheetRowDividerInset), thickness = 1.dp, - color = palette.outlineVariant + color = colorScheme.outlineVariant ) } @@ -212,7 +211,7 @@ internal fun AboutHero( text = stringResource(R.string.about_tagline), fontFamily = BitchatFontFamily, fontSize = 16.sp, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(6.dp)) @@ -238,7 +237,6 @@ internal fun AboutTabBar( onSelect: (AboutTab) -> Unit, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current val colorScheme = MaterialTheme.colorScheme val density = LocalDensity.current @@ -274,7 +272,7 @@ internal fun AboutTabBar( } Box(modifier = Modifier.fillMaxWidth()) { - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) Box( modifier = Modifier // Lambda overload: the offset is animated every frame, and the non-lambda @@ -295,11 +293,10 @@ private fun AboutTabLabel( 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, + targetValue = if (isSelected) colorScheme.primary else colorScheme.onSurfaceVariant, animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), label = "aboutTabLabelColor" ) @@ -328,7 +325,6 @@ private fun AboutInstructionRow( text: String ) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current Row( modifier = Modifier @@ -350,7 +346,7 @@ private fun AboutInstructionRow( fontFamily = BitchatFontFamily, fontSize = 14.sp, lineHeight = 20.sp, - color = palette.textPrimary + color = colorScheme.onSurface ) } } @@ -453,7 +449,6 @@ private fun AboutFeatureRow( subtitle: String ) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current Row( modifier = Modifier @@ -477,14 +472,14 @@ private fun AboutFeatureRow( fontSize = 14.sp, fontWeight = FontWeight.Medium, lineHeight = 20.sp, - color = palette.textPrimary + color = colorScheme.onSurface ) Text( text = subtitle, fontFamily = BitchatFontFamily, fontSize = 12.sp, lineHeight = 17.sp, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) } } @@ -498,10 +493,10 @@ internal fun BitchatBadge( text: String, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme Box( modifier = modifier - .background(palette.accentGreen.copy(alpha = 0.15f), RoundedCornerShape(4.dp)) + .background(colorScheme.primary.copy(alpha = 0.15f), RoundedCornerShape(4.dp)) .padding(horizontal = 5.dp, vertical = 2.dp) ) { Text( @@ -510,7 +505,7 @@ internal fun BitchatBadge( fontSize = 10.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.5.sp, - color = palette.accentGreen + color = colorScheme.primary ) } } @@ -556,11 +551,10 @@ internal fun SheetHeaderBadge( 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), + .background(colorScheme.surface, androidx.compose.foundation.shape.CircleShape), contentAlignment = Alignment.Center ) { Icon( @@ -585,8 +579,8 @@ internal fun SheetDestructiveButton( modifier: Modifier = Modifier, isDestructive: Boolean = true ) { - val palette = LocalBitchatPalette.current - val accent = if (isDestructive) palette.accentRed else palette.accentGreen + val colorScheme = MaterialTheme.colorScheme + val accent = if (isDestructive) colorScheme.error else colorScheme.primary Surface( onClick = onClick, 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 907f7673..67065182 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -82,17 +82,17 @@ private fun ThemeChip( onClick: () -> Unit, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme // 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, + targetValue = if (selected) colorScheme.primary else colorScheme.surfaceVariant, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "themeChipContainer" ) val labelColor by animateColorAsState( - targetValue = if (selected) Color.White else palette.textSecondary, + targetValue = if (selected) Color.White else colorScheme.onSurfaceVariant, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "themeChipLabel" ) @@ -146,12 +146,12 @@ private fun SettingsToggleRow( label = "settingsRowIcon" ) val titleColor by animateColorAsState( - targetValue = if (enabled) palette.textPrimary else palette.textTertiary, + targetValue = if (enabled) colorScheme.onSurface else palette.textTertiary, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "settingsRowTitle" ) val subtitleColor by animateColorAsState( - targetValue = if (enabled) palette.textSecondary else palette.textTertiary, + targetValue = if (enabled) colorScheme.onSurfaceVariant else palette.textTertiary, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "settingsRowSubtitle" ) @@ -213,9 +213,9 @@ private fun SettingsToggleRow( interactionSource = interactionSource, colors = SwitchDefaults.colors( checkedThumbColor = Color.White, - checkedTrackColor = palette.accentGreen, + checkedTrackColor = colorScheme.primary, uncheckedThumbColor = Color.White, - uncheckedTrackColor = palette.surfaceVariant + uncheckedTrackColor = colorScheme.surfaceVariant ) ) } @@ -310,7 +310,7 @@ fun AboutSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Row( @@ -359,7 +359,7 @@ fun AboutSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Column { @@ -383,7 +383,7 @@ fun AboutSheet( HorizontalDivider( modifier = Modifier.padding(start = 54.dp), thickness = 1.dp, - color = palette.outlineVariant + color = colorScheme.outlineVariant ) // Proof of Work Toggle @@ -398,7 +398,7 @@ fun AboutSheet( HorizontalDivider( modifier = Modifier.padding(start = 54.dp), thickness = 1.dp, - color = palette.outlineVariant + color = colorScheme.outlineVariant ) // Tor Toggle @@ -417,9 +417,9 @@ fun AboutSheet( statusIndicator = if (torMode.value == TorMode.ON) { { val statusColor = when { - torStatus.running && torStatus.bootstrapPercent >= 100 -> palette.accentGreen + torStatus.running && torStatus.bootstrapPercent >= 100 -> colorScheme.primary torStatus.running -> palette.accentOrange - else -> palette.accentRed + else -> colorScheme.error } Surface( color = statusColor, @@ -811,7 +811,7 @@ fun AboutSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Column( @@ -828,7 +828,7 @@ fun AboutSheet( fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = palette.textPrimary + color = colorScheme.onSurface ) AnimatedCountLabel( count = powDifficulty, @@ -839,7 +839,7 @@ fun AboutSheet( ), fontFamily = BitchatFontFamily, fontSize = 12.sp, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) } @@ -849,9 +849,9 @@ fun AboutSheet( valueRange = 0f..32f, steps = 31, colors = SliderDefaults.colors( - thumbColor = palette.accentGreen, - activeTrackColor = palette.accentGreen, - inactiveTrackColor = palette.surfaceVariant + thumbColor = colorScheme.primary, + activeTrackColor = colorScheme.primary, + inactiveTrackColor = colorScheme.surfaceVariant ) ) @@ -888,7 +888,7 @@ fun AboutSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Column( @@ -900,9 +900,9 @@ fun AboutSheet( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { val statusColor = when { - torStatus.running && torStatus.bootstrapPercent >= 100 -> palette.accentGreen + torStatus.running && torStatus.bootstrapPercent >= 100 -> colorScheme.primary torStatus.running -> palette.accentOrange - else -> palette.accentRed + else -> colorScheme.error } Surface(color = statusColor, shape = CircleShape, modifier = Modifier.size(10.dp)) {} Text( @@ -914,7 +914,7 @@ fun AboutSheet( fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = palette.textPrimary + color = colorScheme.onSurface ) } if (torStatus.lastLogLine.isNotEmpty()) { @@ -974,7 +974,7 @@ fun AboutSheet( .align(Alignment.TopCenter) .fillMaxWidth() .height(64.dp) - .background(palette.background.copy(alpha = topBarAlpha)) + .background(colorScheme.background.copy(alpha = topBarAlpha)) ) { val dismiss = LocalSheetDismiss.current CloseButton( 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 f1b2fbe2..8993b3ea 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -119,11 +119,12 @@ internal data class TorConnectionVisual( @Composable internal fun rememberTorConnectionVisual(normal: Color): TorConnectionVisual { val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme val torStatus by remember { ArtiTorManager.getInstance() }.statusFlow.collectAsState() // ~28% of the loud accent mixed into the base tint keeps the hue without intensity. val mutedConnecting = lerp(normal, palette.accentOrange, 0.28f) - val mutedFailed = lerp(normal, palette.accentRed, 0.30f) + val mutedFailed = lerp(normal, colorScheme.error, 0.30f) val target = when { torStatus.mode == TorMode.OFF -> TorConnectionVisual(normal, isProgress = false) @@ -268,18 +269,19 @@ fun NoiseSessionIcon( modifier: Modifier = Modifier ) { val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme // The pre-redesign colours for the first two states were `0x87878700`, i.e. alpha 0x87 with // an all-but-transparent RGB - the icons were effectively invisible. They now use the // palette's secondary text colour. val (icon, color, contentDescription) = when (sessionState) { "uninitialized" -> Triple( Icons.Outlined.NoEncryption, - palette.textSecondary, + colorScheme.onSurfaceVariant, stringResource(R.string.cd_ready_for_handshake) ) "handshaking" -> Triple( Icons.Outlined.Sync, - palette.textSecondary, + colorScheme.onSurfaceVariant, stringResource(R.string.cd_handshake_in_progress) ) "established" -> Triple( @@ -290,7 +292,7 @@ fun NoiseSessionIcon( else -> { // "failed" or any other state Triple( Icons.Outlined.Warning, - palette.accentRed, + colorScheme.error, stringResource(R.string.cd_handshake_failed) ) } @@ -365,19 +367,20 @@ fun PeerCounter( modifier: Modifier = Modifier ) { val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme // 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 - Pair(count, if (count > 0) palette.accentGreen else palette.textTertiary) + Pair(count, if (count > 0) colorScheme.primary else palette.textTertiary) } is com.bitchat.android.geohash.ChannelID.Mesh, null -> { // Mesh channel: show Bluetooth-connected peers (excluding self) val count = connectedPeers.size - Pair(count, if (isConnected && count > 0) palette.accentBlue else palette.textTertiary) + Pair(count, if (isConnected && count > 0) colorScheme.secondary else palette.textTertiary) } } @@ -424,7 +427,7 @@ fun PeerCounter( prefix = stringResource(R.string.channel_count_prefix), style = MaterialTheme.typography.bodyMedium, fontSize = HeaderTextSize, - color = if (isConnected) palette.accentGreen else palette.accentRed, + color = if (isConnected) colorScheme.primary else colorScheme.error, fontWeight = FontWeight.Medium ) } @@ -482,7 +485,6 @@ private fun ChannelHeader( onSidebarClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current Box(modifier = Modifier.fillMaxWidth()) { // Back: a chevron alone is unambiguous here and buys back ~40.dp of title space that @@ -520,7 +522,7 @@ private fun ChannelHeader( text = stringResource(R.string.chat_leave), style = MaterialTheme.typography.labelMedium, fontSize = 15.sp, - color = palette.accentRed, + color = colorScheme.error, modifier = Modifier .align(Alignment.CenterEnd) .clip(HeaderClusterShape) @@ -667,7 +669,7 @@ private fun LocationChannelsButton( viewModel: ChatViewModel, onClick: () -> Unit ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme // Get current channel selection from location manager val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() @@ -680,7 +682,7 @@ private fun LocationChannelsButton( // so it is plain "mesh". else -> stringResource(R.string.mesh_label) } - val channelColor = if (isLocation) palette.accentGreen else palette.accentBlue + val channelColor = if (isLocation) colorScheme.primary else colorScheme.secondary // Tor status only tints the globe (location channels). The local mesh stays blue. val torVisual = if (isLocation) { rememberTorConnectionVisual(normal = channelColor) 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 ec3c5d4f..60fd7d4a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -46,7 +46,6 @@ import com.bitchat.android.nostr.LocationNotesManager import com.bitchat.android.nostr.NearbyNotesController 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 @@ -61,7 +60,6 @@ import com.bitchat.android.ui.theme.LocalBitchatPalette @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() @@ -415,17 +413,17 @@ fun ChatScreen(viewModel: ChatViewModel) { ) { Surface( shape = CircleShape, - color = palette.surface, + color = colorScheme.surface, tonalElevation = 3.dp, shadowElevation = 6.dp, - border = BorderStroke(1.dp, palette.accentGreen) + border = BorderStroke(1.dp, colorScheme.primary) ) { IconButton(onClick = { forceScrollToBottom = !forceScrollToBottom }) { Icon( imageVector = Icons.Filled.ArrowDownward, contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom), modifier = Modifier.size(22.dp), - tint = palette.accentGreen + tint = colorScheme.primary ) } } @@ -569,8 +567,6 @@ fun ChatInputSection( showMediaButtons: Boolean, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current - Column( // Flat, slightly translucent screen background — the same treatment as the top bar, so the // two bars are visibly the same kind of surface. No gradient: a soft ramp here just looked @@ -581,7 +577,7 @@ fun ChatInputSection( .background(colorScheme.background.copy(alpha = BarBackgroundAlpha)) ) { // Hairline marking where chrome begins. Faint on purpose — it is a hint, not a border. - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) // Command suggestions box if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { @@ -590,7 +586,7 @@ fun ChatInputSection( onSuggestionClick = onCommandSuggestionClick, modifier = Modifier.fillMaxWidth() ) - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) } // Retain the final populated list while the picker exits. The state layer clears // suggestions together with visibility; without this snapshot the panel would empty and @@ -631,7 +627,7 @@ fun ChatInputSection( onSuggestionClick = onMentionSuggestionClick, modifier = Modifier.fillMaxWidth() ) - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) } } MessageInput( @@ -682,7 +678,6 @@ private fun ChatFloatingHeader( ) { val context = androidx.compose.ui.platform.LocalContext.current val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) } - val palette = LocalBitchatPalette.current Box( modifier = Modifier 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 05425f60..14676d07 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -10,6 +10,7 @@ import com.bitchat.android.model.BitchatMessage import com.bitchat.android.ui.theme.BASE_FONT_SIZE import com.bitchat.android.ui.theme.BitchatPalette import com.bitchat.android.ui.theme.ChatVisualTokens +import com.bitchat.android.ui.theme.colorForPeerSeed import java.text.SimpleDateFormat import java.util.* @@ -57,7 +58,11 @@ fun formatTextMessageSender( ): AnnotatedString { val builder = AnnotatedString.Builder() val isSelf = message.isFromSelf(currentUserNickname, myPeerID) - val senderColor = if (isSelf) palette.accentOrange else getPeerColor(message, palette.isDark) + val senderColor = if (isSelf) { + palette.accentOrange + } else { + colorForPeerSeed(peerColorSeedForMessage(message), palette) + } val senderWeight = FontWeight.SemiBold val (baseName, suffix) = splitSuffix(message.sender) @@ -161,12 +166,12 @@ private fun appendBodyTimestamp( private fun appendMutedTimestamp( builder: AnnotatedString.Builder, message: BitchatMessage, - palette: BitchatPalette, + contentColor: Color, timeFormatter: SimpleDateFormat, ) { builder.pushStyle( SpanStyle( - color = palette.textPrimary.copy(alpha = ChatVisualTokens.MutedTextAlpha), + color = contentColor.copy(alpha = ChatVisualTokens.MutedTextAlpha), fontSize = ChatVisualTokens.SystemTimeFontSize, fontWeight = FontWeight.Normal, ) @@ -186,6 +191,8 @@ fun formatTextMessageBody( message: BitchatMessage, currentUserNickname: String, palette: BitchatPalette, + contentColor: Color, + linkColor: Color, timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()), includeTimestamp: Boolean = true ): AnnotatedString { @@ -196,6 +203,8 @@ fun formatTextMessageBody( content = message.content, currentUserNickname = currentUserNickname, palette = palette, + contentColor = contentColor, + linkColor = linkColor, ) if (includeTimestamp) { @@ -212,13 +221,13 @@ fun formatTextMessageBody( */ fun formatSystemMessage( message: BitchatMessage, - palette: BitchatPalette, + contentColor: Color, timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) ): AnnotatedString { val builder = AnnotatedString.Builder() builder.pushStyle( SpanStyle( - color = palette.textPrimary.copy(alpha = ChatVisualTokens.MutedTextAlpha), + color = contentColor.copy(alpha = ChatVisualTokens.MutedTextAlpha), fontSize = ChatVisualTokens.SystemActionFontSize, fontWeight = FontWeight.Medium, ) @@ -227,7 +236,7 @@ fun formatSystemMessage( builder.append(message.content) builder.pop() - appendMutedTimestamp(builder, message, palette, timeFormatter) + appendMutedTimestamp(builder, message, contentColor, timeFormatter) return builder.toAnnotatedString() } @@ -243,6 +252,7 @@ fun formatMessageHeaderAnnotatedString( currentUserNickname: String, myPeerID: String, palette: BitchatPalette, + contentColor: Color, timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()), includeSender: Boolean = true ): AnnotatedString { @@ -250,11 +260,15 @@ fun formatMessageHeaderAnnotatedString( val isSelf = message.isFromSelf(currentUserNickname, myPeerID) if (message.sender == "system") { - return formatSystemMessage(message, palette, timeFormatter) + return formatSystemMessage(message, contentColor, timeFormatter) } if (includeSender) { - val baseColor = if (isSelf) palette.accentOrange else getPeerColor(message, palette.isDark) + val baseColor = if (isSelf) { + palette.accentOrange + } else { + colorForPeerSeed(peerColorSeedForMessage(message), palette) + } val (baseName, suffix) = splitSuffix(message.sender) builder.pushStyle( @@ -291,71 +305,10 @@ fun formatMessageHeaderAnnotatedString( } } - appendMutedTimestamp(builder, message, palette, timeFormatter) + appendMutedTimestamp(builder, message, contentColor, timeFormatter) return builder.toAnnotatedString() } -/** - * iOS-style peer color assignment using djb2 hash algorithm - * Avoids orange (~30°) reserved for self messages - */ -fun getPeerColor(message: BitchatMessage, isDark: Boolean): Color { - // Create seed from peer identifier (prioritizing stable keys) - val seed = when { - message.senderPeerID?.startsWith("nostr:") == true || message.senderPeerID?.startsWith("nostr_") == true -> { - // For Nostr peers, use the full key if available, otherwise the peer ID - "nostr:${message.senderPeerID.lowercase()}" - } - message.senderPeerID?.length == 16 -> { - // For ephemeral peer IDs, try to get stable Noise key, fallback to peer ID - "noise:${message.senderPeerID.lowercase()}" - } - message.senderPeerID?.length == 64 -> { - // This is already a stable Noise key - "noise:${message.senderPeerID.lowercase()}" - } - else -> { - // Fallback to sender name - message.sender.lowercase() - } - } - - return colorForPeerSeed(seed, isDark) -} - -/** - * 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) - var hash = 5381UL - for (byte in seed.toByteArray()) { - hash = ((hash shl 5) + hash) + byte.toUByte().toULong() - } - - var hue = (hash % 360UL).toDouble() / 360.0 - - // Avoid orange (~30°) reserved for self (matches iOS logic) - val orange = 30.0 / 360.0 - if (kotlin.math.abs(hue - orange) < 0.05) { - hue = (hue + 0.12) % 1.0 - } - - val saturation = if (isDark) 1.0 else 0.85 - val brightness = if (isDark) 1.0 else 0.45 - - return Color.hsv( - hue = (hue * 360).toFloat(), - saturation = saturation.toFloat(), - value = brightness.toFloat() - ) -} - /** * Split a name into base and a '#abcd' suffix if present (matches iOS splitSuffix exactly) */ @@ -387,7 +340,7 @@ internal fun isUnannouncedNickname(displayName: String): Boolean { * iOS-style content formatting with proper hashtag and mention handling. * * Redesign notes: - * - Plain text renders in [BitchatPalette.textPrimary]; colour is reserved for `@mentions`, + * - Plain text renders in Material `onSurface`; 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 @@ -401,10 +354,9 @@ private fun appendIOSFormattedContent( content: String, currentUserNickname: String, palette: BitchatPalette, + contentColor: Color, + linkColor: Color, ) { - 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() @@ -511,7 +463,10 @@ private fun appendIOSFormattedContent( palette.accentOrange } else { // Tint by the *mentioned* peer so a given name looks identical everywhere. - colorForPeerSeed(mentionWithoutAt.lowercase(), palette.isDark) + colorForPeerSeed( + PeerColorSeed(mentionWithoutAt.lowercase(Locale.ROOT)), + palette + ) } val chipAlpha = if (isMentionToMe) MENTION_CHIP_ALPHA_SELF else MENTION_CHIP_ALPHA val mentionWeight = if (isMentionToMe) FontWeight.Bold else FontWeight.SemiBold diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt index b6bf7606..f0647e6c 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.LocalBitchatPalette import androidx.compose.ui.res.stringResource import com.bitchat.android.R import androidx.compose.ui.platform.LocalClipboardManager @@ -35,14 +36,13 @@ fun ChatUserSheet( val coroutineScope = rememberCoroutineScope() val clipboardManager = LocalClipboardManager.current - // iOS system colors (matches LocationChannelsSheet 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 standardPurple = if (isDark) Color(0xFFBF5AF2) else Color(0xFFAF52DE) // iOS purple - val standardRed = Color(0xFFFF3B30) // iOS red - val standardGrey = if (isDark) Color(0xFF8E8E93) else Color(0xFF6D6D70) // iOS grey + val palette = LocalBitchatPalette.current + val standardGreen = colorScheme.primary + val standardBlue = colorScheme.secondary + val standardPurple = palette.accentPurple + val standardRed = colorScheme.error + val standardGrey = colorScheme.onSurfaceVariant if (isPresented) { BitchatBottomSheet( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 4eb32739..4c6d5cdc 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -1195,17 +1195,12 @@ class ChatViewModel( /** * Get consistent color for a mesh peer by ID (iOS-compatible) */ - fun colorForMeshPeer(peerID: String, isDark: Boolean): androidx.compose.ui.graphics.Color { - // Try to get stable Noise key, fallback to peer ID - val seed = "noise:${peerID.lowercase()}" - return colorForPeerSeed(seed, isDark).copy() - } + fun peerColorSeedForMeshPeer(peerID: String): PeerColorSeed = meshPeerColorSeed(peerID) /** * Get consistent color for a Nostr pubkey (iOS-compatible) */ - fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { - return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark) -} + fun peerColorSeedForNostrPubkey(pubkeyHex: String): PeerColorSeed = + geohashViewModel.peerColorSeedForNostrPubkey(pubkeyHex) } 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 7610fedf..8f65bbb1 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.painterResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.ui.theme.colorForPeerSeed import com.bitchat.android.R import com.bitchat.android.ui.theme.LocalBitchatPalette import java.util.* @@ -45,6 +46,7 @@ fun GeohashPeopleList( val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme val myHex = remember(selectedLocationChannel) { when (val channel = selectedLocationChannel) { is com.bitchat.android.geohash.ChannelID.Location -> { @@ -110,7 +112,7 @@ fun GeohashPeopleList( .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding) .padding(top = 10.dp), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Text( @@ -212,14 +214,14 @@ private fun PeopleCard( people: List, row: @Composable (GeoPerson) -> Unit ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme Surface( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding) .padding(top = 10.dp), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { AnimatedRowColumn(items = people, key = { it.id }) { index, person -> @@ -250,7 +252,10 @@ private fun GeohashPersonItem( val (baseNameRaw, suffixRaw) = splitSuffix(person.displayName) val baseName = truncateNickname(baseNameRaw) val suffix = if (showHashSuffix) suffixRaw else "" - val assignedColor = viewModel.colorForNostrPubkey(person.id, palette.isDark) + val assignedColor = colorForPeerSeed( + viewModel.peerColorSeedForNostrPubkey(person.id), + palette + ) val baseColor = if (isMe) palette.accentOrange else assignedColor Row( diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt index bece684a..9565c384 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt @@ -30,10 +30,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.bitchat.android.ui.theme.BitchatTheme import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.res.stringResource @@ -87,15 +87,13 @@ class GeohashPickerActivity : OrientationAwareActivity() { val initialPrecision = geohashToFocus?.length ?: 5 setContent { - MaterialTheme { + BitchatTheme { var currentGeohash by remember { mutableStateOf(geohashToFocus ?: "") } var precision by remember { mutableStateOf(initialPrecision.coerceIn(1, 12)) } var webViewRef by remember { mutableStateOf(null) } - // iOS system-like colors used across app 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) + val standardGreen = colorScheme.primary Scaffold { padding -> Box(Modifier.fillMaxSize()) { diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 4cae30dd..d94e5fff 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -332,10 +332,8 @@ class GeohashViewModel( fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex) fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) - fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { - val seed = "nostr:${pubkeyHex.lowercase()}" - return colorForPeerSeed(seed, isDark).copy() - } + fun peerColorSeedForNostrPubkey(pubkeyHex: String): PeerColorSeed = + nostrPeerColorSeed(pubkeyHex) private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) { geoTimer?.cancel(); geoTimer = null 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 07f633dc..a732b8e8 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -257,7 +257,8 @@ internal fun ComposerActionSurface( content: @Composable (tint: Color) -> Unit ) { val palette = LocalBitchatPalette.current - val accent = if (activeColor == Color.Unspecified) palette.accentGreen else activeColor + val colorScheme = MaterialTheme.colorScheme + val accent = if (activeColor == Color.Unspecified) colorScheme.primary else activeColor val container by animateColorAsState( // A tint rather than a fill. A solid accent disc next to the text you are typing was the @@ -267,7 +268,7 @@ internal fun ComposerActionSurface( label = "composerButtonContainer" ) val tint by animateColorAsState( - targetValue = if (isActive) accent else palette.textSecondary, + targetValue = if (isActive) accent else colorScheme.onSurfaceVariant, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "composerButtonTint" ) @@ -314,6 +315,7 @@ fun MessageInput( modifier: Modifier = Modifier ) { val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme val isFocused = remember { mutableStateOf(false) } val hasText = value.text.isNotBlank() val focusRequester = remember { FocusRequester() } @@ -324,7 +326,7 @@ fun MessageInput( // Recording is the one state worth shouting about, so it overrides focus. val borderColor by animateColorAsState( targetValue = when { - isRecording -> palette.accentRed.copy(alpha = 0.7f) + isRecording -> colorScheme.error.copy(alpha = 0.7f) isFocused.value -> palette.inputOutlineFocused else -> palette.inputOutline }, @@ -371,11 +373,11 @@ fun MessageInput( // user is composing rather than reading, and green-on-black is tiring to // type into. textStyle = MaterialTheme.typography.bodyMedium.copy( - color = palette.textPrimary, + color = colorScheme.onSurface, fontFamily = BitchatFontFamily ), cursorBrush = SolidColor( - if (isRecording) Color.Transparent else palette.textPrimary + if (isRecording) Color.Transparent else colorScheme.onSurface ), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), keyboardActions = KeyboardActions(onSend = { @@ -387,8 +389,8 @@ fun MessageInput( CombinedVisualTransformation( listOf( SlashCommandVisualTransformation( - commandColor = palette.accentGreen, - commandBackground = palette.accentGreen.copy(alpha = 0.14f), + commandColor = colorScheme.primary, + commandBackground = colorScheme.primary.copy(alpha = 0.14f), ), MentionVisualTransformation( mentionColor = palette.accentOrange, @@ -452,7 +454,7 @@ fun MessageInput( secs / 60, secs % 60, maxSecs / 60, maxSecs % 60 ), fontFamily = BitchatFontFamily, - color = palette.accentRed, + color = colorScheme.error, fontSize = (BASE_FONT_SIZE - 4).sp ) } @@ -595,6 +597,7 @@ private fun SendButton( enabled: Boolean = true ) { val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() @@ -602,7 +605,7 @@ private fun SendButton( isActive = enabled, isPressed = isPressed, // Private chats and channels keep their orange identity, disc and glyph together. - activeColor = if (isAccented) palette.accentOrange else palette.accentGreen, + activeColor = if (isAccented) palette.accentOrange else colorScheme.primary, modifier = modifier.clickable( interactionSource = interactionSource, indication = null, @@ -624,13 +627,13 @@ fun CommandSuggestionsBox( onSuggestionClick: (CommandSuggestion) -> Unit, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme Column( modifier = modifier .verticalScroll(rememberScrollState()) - .background(palette.surface) - .border(1.dp, palette.outlineVariant, RoundedCornerShape(8.dp)) + .background(colorScheme.surface) + .border(1.dp, colorScheme.outlineVariant, RoundedCornerShape(8.dp)) .padding(vertical = 6.dp) ) { suggestions.forEach { suggestion: CommandSuggestion -> @@ -683,7 +686,7 @@ fun CommandSuggestionItem( style = MaterialTheme.typography.bodySmall.copy( fontFamily = BitchatFontFamily ), - color = palette.textSecondary, + color = colorScheme.onSurfaceVariant, fontSize = (BASE_FONT_SIZE - 4).sp ) } @@ -709,7 +712,7 @@ fun MentionSuggestionsBox( onSuggestionClick: (String) -> Unit, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme LazyColumn( modifier = modifier @@ -721,8 +724,8 @@ fun MentionSuggestionsBox( ) ) .clip(MentionSuggestionsShape) - .background(palette.surface) - .border(1.dp, palette.outlineVariant, MentionSuggestionsShape), + .background(colorScheme.surface) + .border(1.dp, colorScheme.outlineVariant, MentionSuggestionsShape), contentPadding = PaddingValues(vertical = MentionSuggestionsVerticalPadding) ) { items( diff --git a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt index 9b34285c..57710171 100644 --- a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt +++ b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt @@ -43,11 +43,8 @@ fun LinkPreviewPill( ) { val context = LocalContext.current val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - - // iOS-style colors - val textColor = if (isDark) Color.Green else Color(red = 0f, green = 0.5f, blue = 0f) - val backgroundColor = if (isDark) Color.Gray.copy(alpha = 0.15f) else Color.Gray.copy(alpha = 0.08f) + val textColor = colorScheme.secondary + val backgroundColor = colorScheme.secondaryContainer.copy(alpha = 0.35f) val borderColor = textColor.copy(alpha = 0.3f) // Parse URL for host extraction @@ -87,7 +84,7 @@ fun LinkPreviewPill( Surface( modifier = Modifier.size(60.dp), shape = RoundedCornerShape(8.dp), - color = Color.Blue.copy(alpha = 0.1f) + color = colorScheme.secondaryContainer ) { Box( modifier = Modifier.fillMaxSize(), @@ -97,7 +94,7 @@ fun LinkPreviewPill( imageVector = Icons.Outlined.Link, contentDescription = stringResource(com.bitchat.android.R.string.cd_link), modifier = Modifier.size(24.dp), - tint = Color.Blue + tint = colorScheme.secondary ) } } 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 7581dd46..ca5fd26e 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -28,6 +28,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField @@ -40,6 +42,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -71,7 +74,9 @@ import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette import com.bitchat.android.wifiaware.WifiAwareController import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull /** * Leading column width matching settings rows: 22.dp glyph + 16.dp gutter before title text. @@ -152,8 +157,8 @@ fun LocationChannelsSheet( val colorScheme = MaterialTheme.colorScheme val palette = LocalBitchatPalette.current - val standardGreen = palette.accentGreen - val standardBlue = palette.accentBlue + val standardGreen = colorScheme.primary + val standardBlue = colorScheme.secondary val nearbyChannels = remember(availableChannels) { availableChannels.filter { it.level != GeohashChannelLevel.BUILDING } @@ -185,7 +190,12 @@ fun LocationChannelsSheet( Box(modifier = Modifier.fillMaxWidth()) { LazyColumn( state = listState, - modifier = Modifier.fillMaxSize(), + // Edge-to-edge + adjustResize reports the keyboard as WindowInsets.ime — + // without consuming it here the list stays full-height and the teleport + // field can't scroll above the keyboard. + modifier = Modifier + .fillMaxSize() + .imePadding(), contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(0.dp) ) { @@ -203,7 +213,7 @@ fun LocationChannelsSheet( .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding) .padding(top = 10.dp), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { ChannelOptionRow( @@ -253,7 +263,7 @@ fun LocationChannelsSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { ChannelOptionRow( @@ -292,7 +302,7 @@ fun LocationChannelsSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Column { @@ -353,7 +363,7 @@ fun LocationChannelsSheet( text = stringResource(R.string.location_permission_denied), fontSize = 12.sp, fontFamily = BitchatFontFamily, - color = palette.accentRed + color = colorScheme.error ) TextButton( onClick = { @@ -389,7 +399,7 @@ fun LocationChannelsSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Column { @@ -443,12 +453,7 @@ fun LocationChannelsSheet( customError = null }, onFocusGained = { - coroutineScope.launch { - sheetState.expand() - listState.animateScrollToItem( - index = listState.layoutInfo.totalItemsCount - 1 - ) - } + coroutineScope.launch { sheetState.expand() } }, onOpenMap = { val normalized = customGeohash.trim().lowercase().replace("#", "") @@ -497,7 +502,7 @@ fun LocationChannelsSheet( text = shownError, fontSize = 12.sp, fontFamily = BitchatFontFamily, - color = palette.accentRed, + color = colorScheme.error, modifier = Modifier.padding( start = AboutHorizontalPadding + ChannelRowHorizontal, top = 8.dp @@ -518,7 +523,7 @@ fun LocationChannelsSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { ChannelSettingsToggleRow( @@ -638,7 +643,6 @@ private fun ChannelOptionRow( onClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current val (baseTitle, countSuffix) = splitTitleAndCount(title) Row( @@ -657,7 +661,7 @@ private fun ChannelOptionRow( Box( modifier = Modifier .size(ChannelSelectedDot) - .background(palette.accentGreen, CircleShape) + .background(colorScheme.primary, CircleShape) ) } leadingIcon != null -> { @@ -686,7 +690,7 @@ private fun ChannelOptionRow( fontSize = 14.sp, fontFamily = BitchatFontFamily, fontWeight = if (titleBold) FontWeight.SemiBold else FontWeight.Medium, - color = titleColor ?: palette.textPrimary + color = titleColor ?: colorScheme.onSurface ) countSuffix?.let { count -> AnimatedCountLabel( @@ -694,7 +698,7 @@ private fun ChannelOptionRow( text = count, fontSize = 11.sp, fontFamily = BitchatFontFamily, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) } } @@ -703,7 +707,7 @@ private fun ChannelOptionRow( fontSize = 12.sp, fontFamily = BitchatFontFamily, lineHeight = 17.sp, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) } @@ -719,7 +723,7 @@ private fun ChannelBookmarkButton( bookmarked: Boolean, onClick: () -> Unit ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme Box( modifier = Modifier .size(36.dp) @@ -737,7 +741,7 @@ private fun ChannelBookmarkButton( contentDescription = stringResource( if (bookmarked) R.string.cd_remove_bookmark else R.string.cd_add_bookmark ), - tint = if (bookmarked) palette.accentGreen else palette.textSecondary, + tint = if (bookmarked) colorScheme.primary else colorScheme.onSurfaceVariant, modifier = Modifier.size(22.dp) ) } @@ -745,7 +749,7 @@ private fun ChannelBookmarkButton( @Composable private fun ChannelLoadingRow() { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme Row( modifier = Modifier .fillMaxWidth() @@ -760,14 +764,14 @@ private fun ChannelLoadingRow() { CircularProgressIndicator( modifier = Modifier.size(16.dp), strokeWidth = 2.dp, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) } Text( text = stringResource(R.string.finding_nearby_channels), fontSize = 12.sp, fontFamily = BitchatFontFamily, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) } } @@ -785,6 +789,10 @@ private fun CustomGeohashRow( ) { val colorScheme = MaterialTheme.colorScheme val palette = LocalBitchatPalette.current + val density = LocalDensity.current + val imeInsets = WindowInsets.ime + val coroutineScope = rememberCoroutineScope() + val bringIntoViewRequester = remember { BringIntoViewRequester() } val normalized = customGeohash.trim().lowercase().replace("#", "") val isValid = validateGeohash(normalized) // Typing the last character of a valid geohash arms the button; cross-fading both the label @@ -798,7 +806,7 @@ private fun CustomGeohashRow( targetValue = if (isValid) { colorScheme.primary.copy(alpha = 0.16f) } else { - palette.surfaceVariant + colorScheme.surfaceVariant }, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "teleportContainer" @@ -809,6 +817,7 @@ private fun CustomGeohashRow( Row( modifier = Modifier .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester) .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), verticalAlignment = Alignment.CenterVertically ) { @@ -847,7 +856,22 @@ private fun CustomGeohashRow( singleLine = true, modifier = Modifier .weight(1f) - .onFocusChanged { if (it.isFocused) onFocusGained() }, + .onFocusChanged { focusState -> + if (!focusState.isFocused) return@onFocusChanged + onFocusGained() + // Wait until IME insets have landed so the LazyColumn has shrunk; bringIntoView + // against the full-height viewport leaves the field tucked under the keyboard. + // Re-request once the IME animation settles — early insets are still growing. + coroutineScope.launch { + withTimeoutOrNull(750) { + snapshotFlow { imeInsets.getBottom(density) } + .first { it > 0 } + } + bringIntoViewRequester.bringIntoView() + delay(BitchatMotion.STANDARD_MS.toLong()) + bringIntoViewRequester.bringIntoView() + } + }, decorationBox = { inner -> if (customGeohash.isEmpty()) { Text( @@ -871,7 +895,7 @@ private fun CustomGeohashRow( Icon( imageVector = Icons.Filled.Map, contentDescription = stringResource(R.string.cd_open_map), - tint = palette.textSecondary, + tint = colorScheme.onSurfaceVariant, modifier = Modifier.size(22.dp) ) } @@ -937,7 +961,7 @@ private fun ChannelSettingsToggleRow( fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = if (enabled) palette.textPrimary else palette.textTertiary + color = if (enabled) colorScheme.onSurface else palette.textTertiary ) statusIndicator?.invoke() } @@ -946,7 +970,7 @@ private fun ChannelSettingsToggleRow( fontFamily = BitchatFontFamily, fontSize = 12.sp, lineHeight = 17.sp, - color = if (enabled) palette.textSecondary else palette.textTertiary + color = if (enabled) colorScheme.onSurfaceVariant else palette.textTertiary ) } Spacer(modifier = Modifier.width(16.dp)) @@ -956,9 +980,9 @@ private fun ChannelSettingsToggleRow( enabled = enabled, colors = SwitchDefaults.colors( checkedThumbColor = Color.White, - checkedTrackColor = palette.accentGreen, + checkedTrackColor = colorScheme.primary, uncheckedThumbColor = Color.White, - uncheckedTrackColor = palette.surfaceVariant + uncheckedTrackColor = colorScheme.surfaceVariant ) ) } 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 70f98d5a..489ddaf4 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -21,7 +21,6 @@ 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 for MainHeader. @@ -35,7 +34,6 @@ fun LocationNotesButton( modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current val context = LocalContext.current val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() @@ -54,7 +52,7 @@ fun LocationNotesButton( if (selectedLocationChannel is ChannelID.Mesh && locationEnabled) { val hasNotes = notesCount > 0 val contentDescription = stringResource(R.string.cd_location_notes) - val normalTint = if (hasNotes) colorScheme.primary else palette.textSecondary + val normalTint = if (hasNotes) colorScheme.primary else colorScheme.onSurfaceVariant val torVisual = rememberTorConnectionVisual(normal = normalTint) Box( diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt index c250f6a1..f3c49480 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -5,7 +5,6 @@ import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -51,10 +50,8 @@ fun LocationNotesSheet( modifier: Modifier = Modifier ) { val context = LocalContext.current - val isDark = isSystemInDarkTheme() - - // iOS color scheme - val accentGreen = if (isDark) Color.Green else Color(0xFF008000) // dark: green, light: dark green (0, 0.5, 0) + val colorScheme = MaterialTheme.colorScheme + val accentGreen = colorScheme.primary // Managers val notesManager = remember { LocationNotesManager.getInstance() } @@ -468,7 +465,6 @@ private fun LocationNotesInputSection( nickname: String?, onSend: () -> Unit ) { - val isDark = isSystemInDarkTheme() val colorScheme = MaterialTheme.colorScheme Column( @@ -556,10 +552,8 @@ private fun LocationNotesInputSection( modifier = Modifier.size(20.dp), tint = if (!sendButtonEnabled) { colorScheme.onSurface.copy(alpha = 0.5f) - } else if (isDark) { - Color.Black // Black arrow on green in dark theme } else { - Color.White // White arrow on green in light theme + colorScheme.onPrimary } ) } 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 9e1639b0..4bb36ecc 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -47,6 +47,7 @@ import com.bitchat.android.identity.SecureIdentityStateManager 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.ui.theme.colorForPeerSeed import com.bitchat.android.nostr.GeohashAliasRegistry import com.bitchat.android.nostr.GeohashConversationRegistry import com.bitchat.android.services.ContactDirectory @@ -132,7 +133,7 @@ fun MeshPeerListSheet( .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding) .padding(top = 10.dp), - color = LocalBitchatPalette.current.surface, + color = MaterialTheme.colorScheme.surface, shape = AboutCardShape ) { Column { @@ -222,7 +223,7 @@ fun MeshPeerListSheet( Icon( imageVector = Icons.Outlined.QrCode, contentDescription = stringResource(R.string.verify_title), - tint = LocalBitchatPalette.current.textSecondary, + tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(22.dp) ) } @@ -265,7 +266,7 @@ private fun ChannelRow( Box( modifier = Modifier .size(SheetRowSelectedDot) - .background(palette.accentGreen, CircleShape) + .background(colorScheme.primary, CircleShape) ) } else if (unreadCount > 0) { UnreadBadge(count = unreadCount, colorScheme = colorScheme) @@ -286,7 +287,7 @@ private fun ChannelRow( text = channel, fontFamily = BitchatFontFamily, fontSize = 14.sp, - color = if (isSelected) colorScheme.primary else palette.textPrimary, + color = if (isSelected) colorScheme.primary else colorScheme.onSurface, fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, modifier = Modifier.weight(1f), maxLines = 1, @@ -331,7 +332,7 @@ fun PeopleSection( .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding) .padding(top = 10.dp), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Column { @@ -588,7 +589,10 @@ private fun PeerItem( // Get consistent peer color (iOS-compatible) val palette = LocalBitchatPalette.current - val assignedColor = viewModel.colorForMeshPeer(peerID, palette.isDark) + val assignedColor = colorForPeerSeed( + viewModel.peerColorSeedForMeshPeer(peerID), + palette + ) val baseColor = if (isMe) palette.accentOrange else assignedColor Row( @@ -606,7 +610,7 @@ private fun PeerItem( Box( modifier = Modifier .size(SheetRowSelectedDot) - .background(palette.accentGreen, CircleShape) + .background(colorScheme.primary, CircleShape) ) } else if (hasUnreadDM) { Icon( @@ -642,7 +646,7 @@ private fun PeerItem( else -> "Routed" }, modifier = Modifier.size(PeerRowIconSize), - tint = palette.textSecondary + tint = colorScheme.onSurfaceVariant ) } } @@ -679,7 +683,7 @@ private fun PeerItem( imageVector = Icons.Filled.Verified, contentDescription = stringResource(R.string.verify_title), modifier = Modifier.size(16.dp), - tint = palette.accentGreen + tint = colorScheme.primary ) } } @@ -866,7 +870,6 @@ fun PrivateChatSheet( val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true ) - val palette = LocalBitchatPalette.current if (isPresented) { BitchatBottomSheet( @@ -879,7 +882,7 @@ fun PrivateChatSheet( ) { Spacer(modifier = Modifier.height(64.dp)) - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) // Messages list var forceScrollToBottom by remember { mutableStateOf(false) } 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 a0581a51..c8e05af0 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -498,6 +498,7 @@ fun MessageItem( currentUserNickname = currentUserNickname, myPeerID = meshService.myPeerID, palette = palette, + contentColor = colorScheme.onSurface, timeFormatter = timeFormatter, includeSender = showSender ) @@ -516,7 +517,7 @@ fun MessageItem( }, onLongPress = { onMessageLongPress?.invoke(message) }, fontFamily = BitchatFontFamily, - color = palette.textPrimary, + color = colorScheme.onSurface, ) // Try to load the file packet from the path @@ -564,14 +565,14 @@ fun MessageItem( .align(Alignment.TopEnd) .padding(4.dp) .size(22.dp) - .background(palette.surfaceVariant.copy(alpha = 0.85f), CircleShape) + .background(colorScheme.surfaceVariant.copy(alpha = 0.85f), CircleShape) .clickable { onCancelTransfer?.invoke(message) }, contentAlignment = Alignment.Center ) { Icon( imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.cd_cancel), - tint = palette.textPrimary, + tint = colorScheme.onSurface, modifier = Modifier.size(14.dp) ) } @@ -591,10 +592,10 @@ fun MessageItem( if (message.sender == "system") { // Background narration: `// Tor started. Routing all chats…` - val annotatedText = remember(message, palette) { + val annotatedText = remember(message, colorScheme.onSurface) { formatSystemMessage( message = message, - palette = palette, + contentColor = colorScheme.onSurface, timeFormatter = timeFormatter ) } @@ -614,7 +615,7 @@ fun MessageItem( softWrap = true, overflow = TextOverflow.Visible, style = ChatVisualTokens.SystemActionStyle.copy( - color = palette.textPrimary.copy(alpha = ChatVisualTokens.MutedTextAlpha), + color = colorScheme.onSurface.copy(alpha = ChatVisualTokens.MutedTextAlpha), ) ) } else { @@ -660,11 +661,20 @@ internal fun TextMessageLayout( } // 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) { + val bodyText = remember( + displayMessage, + currentUserNickname, + palette, + colorScheme.onSurface, + colorScheme.secondary, + timeFormatter + ) { formatTextMessageBody( message = displayMessage, currentUserNickname = currentUserNickname, palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, ) } @@ -728,7 +738,7 @@ internal fun TextMessageLayout( fontFamily = BitchatFontFamily, softWrap = true, overflow = TextOverflow.Visible, - style = MessageBodyTextStyle.copy(color = palette.textPrimary), + style = MessageBodyTextStyle.copy(color = colorScheme.onSurface), ) } } @@ -736,7 +746,6 @@ internal fun TextMessageLayout( @Composable fun DeliveryStatusIcon(status: DeliveryStatus) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current // Status advances on its own as acks come back, so a hard glyph swap reads as a flicker. // Keyed on the status *type* rather than the instance, because Delivered/Read carry a @@ -758,9 +767,9 @@ fun DeliveryStatusIcon(status: DeliveryStatus) { DeliveryStatus.Delivered::class -> Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.8f), FontWeight.Normal) DeliveryStatus.Read::class -> - Triple(R.string.status_delivered, palette.accentBlue, FontWeight.Bold) + Triple(R.string.status_delivered, colorScheme.secondary, FontWeight.Bold) DeliveryStatus.Failed::class -> - Triple(R.string.status_failed, palette.accentRed, FontWeight.Normal) + Triple(R.string.status_failed, colorScheme.error, FontWeight.Normal) // A single subdued check, without the numeric label. else -> Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal) diff --git a/app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt b/app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt new file mode 100644 index 00000000..b6eac7cd --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt @@ -0,0 +1,33 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import java.util.Locale + +/** + * Stable, presentation-neutral identity used to derive a peer hue. + * + * ViewModels may expose this value, but only the UI theme resolves it to a rendered color. + */ +@JvmInline +value class PeerColorSeed(val value: String) + +fun meshPeerColorSeed(peerID: String): PeerColorSeed = + PeerColorSeed("noise:${peerID.lowercase(Locale.ROOT)}") + +fun nostrPeerColorSeed(pubkeyHex: String): PeerColorSeed = + PeerColorSeed("nostr:${pubkeyHex.lowercase(Locale.ROOT)}") + +fun peerColorSeedForMessage(message: BitchatMessage): PeerColorSeed { + val value = when { + message.senderPeerID?.startsWith("nostr:") == true || + message.senderPeerID?.startsWith("nostr_") == true -> { + "nostr:${message.senderPeerID.lowercase(Locale.ROOT)}" + } + message.senderPeerID?.length == 16 || message.senderPeerID?.length == 64 -> { + "noise:${message.senderPeerID.lowercase(Locale.ROOT)}" + } + else -> message.sender.lowercase(Locale.ROOT) + } + + return PeerColorSeed(value) +} diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt index d4ba21fe..40da30be 100644 --- a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -10,7 +10,6 @@ import androidx.compose.material.icons.outlined.Warning as OutlinedWarning import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -70,9 +69,9 @@ fun SecurityVerificationSheet( val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle() - val isDark = isSystemInDarkTheme() - val accent = if (isDark) Color.Green else Color(0xFF008000) - val boxColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.06f) + val colorScheme = MaterialTheme.colorScheme + val accent = colorScheme.primary + val boxColor = colorScheme.surfaceVariant val peerHexRegex = remember { Regex("^[0-9a-fA-F]{16}$") } BitchatBottomSheet( diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt index 4fc5bc34..a84c3c94 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -19,7 +19,6 @@ import androidx.compose.animation.Crossfade import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -99,8 +98,7 @@ fun VerificationSheet( ) { if (!isPresented) return - val isDark = isSystemInDarkTheme() - val accent = if (isDark) Color.Green else Color(0xFF008000) + val accent = MaterialTheme.colorScheme.primary var selectedTab by remember { mutableStateOf(0) } // 0 = My Code, 1 = Scan val nickname by viewModel.nickname.collectAsStateWithLifecycle() 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 5627111d..c646d570 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 @@ -55,6 +55,7 @@ fun AudioMessageItem( currentUserNickname = currentUserNickname, myPeerID = meshService.myPeerID, palette = palette, + contentColor = colorScheme.onSurface, timeFormatter = timeFormatter, includeSender = showSender ) @@ -73,7 +74,7 @@ fun AudioMessageItem( }, onLongPress = { onMessageLongPress?.invoke(message) }, fontFamily = BitchatFontFamily, - color = palette.textPrimary, + color = colorScheme.onSurface, ) 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 30387944..5d9908fc 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 @@ -53,6 +53,7 @@ fun ImageMessageItem( currentUserNickname = currentUserNickname, myPeerID = meshService.myPeerID, palette = palette, + contentColor = colorScheme.onSurface, timeFormatter = timeFormatter, includeSender = showSender ) @@ -71,7 +72,7 @@ fun ImageMessageItem( }, onLongPress = { onMessageLongPress?.invoke(message) }, fontFamily = BitchatFontFamily, - color = palette.textPrimary, + color = colorScheme.onSurface, ) val context = LocalContext.current diff --git a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt index dab45ff5..96ef57d4 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt @@ -5,40 +5,18 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color /** - * Semantic design tokens for the bitchat redesign. + * Bitchat-specific color tokens that do not have a faithful Material 3 semantic role. * - * 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. + * Standard backgrounds, surfaces, text, outlines, primary/secondary accents, and errors belong + * to [androidx.compose.material3.MaterialTheme.colorScheme]. Keeping only the extra app semantics + * here lets Material components inherit correct defaults without losing Bitchat's identity. */ @Immutable data class BitchatPalette( - /** Authoritative dark-mode flag. Prefer this over summing background channels. */ - val isDark: Boolean, - - // MARK: - Surfaces - /** Screen, header and composer background. True black in dark mode. */ - 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: - Form controls /** * Resting border for text inputs. Deliberately a neutral grey rather than the green-tinted - * [outline]: the composer is the one surface the user stares at while typing, and a green - * cast there made the whole bottom of the screen read as "terminal chrome" instead of a - * place to write. + * Material outline: the composer is the one surface the user stares at while typing. */ val inputOutline: Color, /** Border for a focused text input. A step brighter, still neutral. */ @@ -54,72 +32,45 @@ data class BitchatPalette( /** Resting disc behind the composer's action glyphs. Neutral grey. */ val inputButton: Color, - // MARK: - Text - /** Message bodies and row titles. Neutral, not green. */ - val textPrimary: Color, - /** Subtitles, secondary metadata, system/action messages. */ - val textSecondary: Color, + // MARK: - Extra semantics /** 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, + + // MARK: - Deterministic peer colors + /** Chroma applied after deriving a peer's stable hue. */ + val peerColorSaturation: Float, + /** Brightness applied after deriving a peer's stable hue. */ + val peerColorValue: Float, ) val DarkBitchatPalette = BitchatPalette( - isDark = true, - // True black. OLED panels switch these pixels off entirely, which is both the terminal - // aesthetic the app is going for and a real battery win on a screen that is mostly - // background. - background = Color(0xFF000000), - surface = Color(0xFF0E150E), - surfaceVariant = Color(0xFF182118), - outline = Color(0xFF2A3A2A), - outlineVariant = Color(0xFF1C271C), inputOutline = Color(0xFF333635), inputOutlineFocused = Color(0xFF5A605D), inputSurface = Color(0xFF0B0B0B), inputSurfaceFocused = Color(0xFF151515), inputButton = Color(0xFF1E1E1E), - textPrimary = Color(0xFFF5F5F5), - textSecondary = Color(0xFF9AA69A), textTertiary = Color(0xFF6B776B), - accentGreen = Color(0xFF32D74B), - accentBlue = Color(0xFF0A84FF), accentOrange = Color(0xFFFF9F0A), - accentRed = Color(0xFFFF453A), accentPurple = Color(0xFFBF5AF2), + peerColorSaturation = 1f, + peerColorValue = 1f, ) val LightBitchatPalette = BitchatPalette( - isDark = false, - background = Color(0xFFFFFFFF), - surface = Color(0xFFF2F6F2), - surfaceVariant = Color(0xFFE7EDE7), - outline = Color(0xFFCBD6CB), - outlineVariant = Color(0xFFDEE6DE), inputOutline = Color(0xFFCFD3D1), inputOutlineFocused = Color(0xFF8E9490), inputSurface = Color(0xFFFAFAFA), inputSurfaceFocused = Color(0xFFF2F2F2), inputButton = Color(0xFFE8E8E8), - textPrimary = Color(0xFF131A13), - textSecondary = Color(0xFF4C574C), textTertiary = Color(0xFF757F75), - accentGreen = Color(0xFF248A3D), - accentBlue = Color(0xFF007AFF), accentOrange = Color(0xFFFF9500), - accentRed = Color(0xFFD70015), accentPurple = Color(0xFFAF52DE), + peerColorSaturation = 0.85f, + peerColorValue = 0.45f, ) val LocalBitchatPalette = staticCompositionLocalOf { DarkBitchatPalette } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt b/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt new file mode 100644 index 00000000..2a0e0a38 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt @@ -0,0 +1,30 @@ +package com.bitchat.android.ui.theme + +import androidx.compose.ui.graphics.Color +import com.bitchat.android.ui.PeerColorSeed +import kotlin.math.abs + +/** + * Resolve a peer's stable seed using the active Bitchat theme's explicit chroma tokens. + * + * The djb2 hash and hue adjustment are byte-identical to the iOS implementation. Orange is + * avoided because it is reserved for the current user. + */ +fun colorForPeerSeed(seed: PeerColorSeed, palette: BitchatPalette): Color { + var hash = 5381UL + for (byte in seed.value.toByteArray()) { + hash = ((hash shl 5) + hash) + byte.toUByte().toULong() + } + + var hue = (hash % 360UL).toDouble() / 360.0 + val orange = 30.0 / 360.0 + if (abs(hue - orange) < 0.05) { + hue = (hue + 0.12) % 1.0 + } + + return Color.hsv( + hue = (hue * 360).toFloat(), + saturation = palette.peerColorSaturation, + value = palette.peerColorValue + ) +} 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 09d03959..f2c4b0b7 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 @@ -17,40 +17,51 @@ 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. -// 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) +// Standard UI semantics live in Material so stock components and custom Bitchat composables +// share one source of truth. LocalBitchatPalette below only supplies app-specific extra colors. +internal val DarkBitchatColorScheme = darkColorScheme( + primary = Color(0xFF32D74B), onPrimary = Color.Black, - secondary = Color(0xFF2ECB10), // Darker green + primaryContainer = Color(0xFF163D1D), + onPrimaryContainer = Color(0xFFB8F5C1), + secondary = Color(0xFF0A84FF), onSecondary = Color.Black, - background = DarkBitchatPalette.background, - onBackground = Color(0xFF39FF14), // Green on near-black - surface = DarkBitchatPalette.surface, - onSurface = Color(0xFF39FF14), // Green text - surfaceVariant = DarkBitchatPalette.surfaceVariant, - onSurfaceVariant = DarkBitchatPalette.textSecondary, - outline = DarkBitchatPalette.outline, - outlineVariant = DarkBitchatPalette.outlineVariant, - error = DarkBitchatPalette.accentRed, + secondaryContainer = Color(0xFF082E54), + onSecondaryContainer = Color(0xFFC2E0FF), + tertiary = DarkBitchatPalette.accentOrange, + onTertiary = Color.Black, + background = Color(0xFF000000), + onBackground = Color(0xFFF5F5F5), + surface = Color(0xFF0E150E), + onSurface = Color(0xFFF5F5F5), + surfaceVariant = Color(0xFF182118), + onSurfaceVariant = Color(0xFF9AA69A), + outline = Color(0xFF2A3A2A), + outlineVariant = Color(0xFF1C271C), + error = Color(0xFFFF453A), onError = Color.Black ) -private val LightColorScheme = lightColorScheme( - primary = Color(0xFF008000), // Dark green +internal val LightBitchatColorScheme = lightColorScheme( + primary = Color(0xFF248A3D), onPrimary = Color.White, - secondary = Color(0xFF006600), // Even darker green + primaryContainer = Color(0xFFD5F1D8), + onPrimaryContainer = Color(0xFF0A3212), + secondary = Color(0xFF007AFF), onSecondary = Color.White, - background = LightBitchatPalette.background, - onBackground = Color(0xFF008000), // Dark green on off-white - surface = LightBitchatPalette.surface, - onSurface = Color(0xFF008000), // Dark green text - surfaceVariant = LightBitchatPalette.surfaceVariant, - onSurfaceVariant = LightBitchatPalette.textSecondary, - outline = LightBitchatPalette.outline, - outlineVariant = LightBitchatPalette.outlineVariant, - error = LightBitchatPalette.accentRed, + secondaryContainer = Color(0xFFD6E9FF), + onSecondaryContainer = Color(0xFF002C5C), + tertiary = LightBitchatPalette.accentOrange, + onTertiary = Color.Black, + background = Color(0xFFFFFFFF), + onBackground = Color(0xFF131A13), + surface = Color(0xFFF2F6F2), + onSurface = Color(0xFF131A13), + surfaceVariant = Color(0xFFE7EDE7), + onSurfaceVariant = Color(0xFF4C574C), + outline = Color(0xFFCBD6CB), + outlineVariant = Color(0xFFDEE6DE), + error = Color(0xFFD70015), onError = Color.White ) @@ -71,7 +82,7 @@ fun BitchatTheme( } } - val colorScheme = if (shouldUseDark) DarkColorScheme else LightColorScheme + val colorScheme = if (shouldUseDark) DarkBitchatColorScheme else LightBitchatColorScheme val palette = if (shouldUseDark) DarkBitchatPalette else LightBitchatPalette val view = LocalView.current 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 d58d3b4a..59f0eb73 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -7,16 +7,18 @@ import androidx.compose.ui.unit.sp import com.bitchat.android.model.BitchatMessage import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.ChatVisualTokens +import com.bitchat.android.ui.theme.DarkBitchatColorScheme import com.bitchat.android.ui.theme.DarkBitchatPalette +import com.bitchat.android.ui.theme.LightBitchatColorScheme import com.bitchat.android.ui.theme.LightBitchatPalette import com.bitchat.android.ui.theme.MessageBodyTextStyle import com.bitchat.android.ui.theme.MessageSenderTextStyle +import com.bitchat.android.ui.theme.colorForPeerSeed 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 import org.junit.runner.RunWith @@ -33,6 +35,7 @@ class ChatUIUtilsTest { } private val palette = DarkBitchatPalette + private val colorScheme = DarkBitchatColorScheme private fun message( content: String, @@ -71,6 +74,8 @@ class ChatUIUtilsTest { message = message("hello there"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, ) @@ -89,6 +94,8 @@ class ChatUIUtilsTest { message = message("mined", powDifficulty = 8), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, ) @@ -106,6 +113,8 @@ class ChatUIUtilsTest { message = message("hello there"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, includeTimestamp = false, ) @@ -119,12 +128,14 @@ class ChatUIUtilsTest { message = message("plain words"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, includeTimestamp = false, ) val textStyle = body.spanStyles.first { it.start == 0 } - assertEquals(palette.textPrimary, textStyle.item.color) + assertEquals(colorScheme.onSurface, textStyle.item.color) } @Test @@ -146,6 +157,8 @@ class ChatUIUtilsTest { message = message("my own words", sender = "bob"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, includeTimestamp = false, ) @@ -170,6 +183,8 @@ class ChatUIUtilsTest { message = message("hey @carol#04af what's up"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, includeTimestamp = false, ) @@ -188,6 +203,8 @@ class ChatUIUtilsTest { message = message("ping @bob now"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, includeTimestamp = false, ) @@ -208,11 +225,13 @@ class ChatUIUtilsTest { message = message("cc @carol#04af"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, includeTimestamp = false, ) - val expected = colorForPeerSeed("carol#04af", palette.isDark) + val expected = colorForPeerSeed(PeerColorSeed("carol#04af"), palette) val chip = mentionChipSpans(body).single() assertEquals(expected.copy(alpha = MENTION_CHIP_ALPHA), chip.item.background) } @@ -223,6 +242,8 @@ class ChatUIUtilsTest { message = message("no mentions in here"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, includeTimestamp = false, ) @@ -236,7 +257,7 @@ class ChatUIUtilsTest { 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, + contentColor = colorScheme.onSurface, timeFormatter = timeFormatter, ).text @@ -249,7 +270,7 @@ class ChatUIUtilsTest { // messages despite being lower-priority narration. val annotated = formatSystemMessage( message = message("tor restarting", sender = "system"), - palette = palette, + contentColor = colorScheme.onSurface, timeFormatter = timeFormatter, ) @@ -260,7 +281,7 @@ class ChatUIUtilsTest { fun `system action and timestamp use their exported weights sizes and opacity`() { val annotated = formatSystemMessage( message = message("tor restarting", sender = "system"), - palette = palette, + contentColor = colorScheme.onSurface, timeFormatter = timeFormatter, ) @@ -273,10 +294,10 @@ class ChatUIUtilsTest { assertEquals(12.sp, action.fontSize) assertEquals(FontWeight.Medium, action.fontWeight) - assertEquals(palette.textPrimary.copy(alpha = 0.5f), action.color) + assertEquals(colorScheme.onSurface.copy(alpha = 0.5f), action.color) assertEquals(10.sp, time.fontSize) assertEquals(FontWeight.Normal, time.fontWeight) - assertEquals(palette.textPrimary.copy(alpha = 0.5f), time.color) + assertEquals(colorScheme.onSurface.copy(alpha = 0.5f), time.color) } // MARK: - Sender label @@ -327,12 +348,25 @@ class ChatUIUtilsTest { // MARK: - Peer colors + @Test + fun `peer seed factories normalize identities without resolving UI colors`() { + assertEquals( + PeerColorSeed("noise:abcdef"), + meshPeerColorSeed("ABCDEF") + ) + assertEquals( + PeerColorSeed("nostr:abcdef"), + nostrPeerColorSeed("ABCDEF") + ) + } + @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 seed = PeerColorSeed("noise:abc") + val dark = colorForPeerSeed(seed, DarkBitchatPalette) + val light = colorForPeerSeed(seed, LightBitchatPalette) val darkHsv = FloatArray(3) val lightHsv = FloatArray(3) @@ -342,13 +376,18 @@ class ChatUIUtilsTest { assertEquals(darkHsv[0].toDouble(), lightHsv[0].toDouble(), 1.0) assertEquals(1.0, darkHsv[1].toDouble(), 0.01) assertEquals(1.0, darkHsv[2].toDouble(), 0.01) + assertEquals(0.85, lightHsv[1].toDouble(), 0.01) + assertEquals(0.45, lightHsv[2].toDouble(), 0.01) } @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 color = colorForPeerSeed( + PeerColorSeed("noise:seed$i"), + DarkBitchatPalette + ) val hsv = FloatArray(3) rgbToHsv(color.red, color.green, color.blue, hsv) val distanceFromOrange = kotlin.math.abs(hsv[0] - 30f) @@ -360,11 +399,12 @@ class ChatUIUtilsTest { } @Test - fun `light palette is not the dark palette`() { - assertNull(null) - assertEquals(Color(0xFFF5F5F5), DarkBitchatPalette.textPrimary) - assertTrue(LightBitchatPalette.textPrimary != DarkBitchatPalette.textPrimary) - assertTrue(LightBitchatPalette.isDark != DarkBitchatPalette.isDark) + fun `material owns standard text while Bitchat palette owns peer chroma`() { + assertEquals(Color(0xFFF5F5F5), DarkBitchatColorScheme.onSurface) + assertTrue(LightBitchatColorScheme.onSurface != DarkBitchatColorScheme.onSurface) + assertTrue( + LightBitchatPalette.peerColorValue != DarkBitchatPalette.peerColorValue + ) } private fun rgbToHsv(r: Float, g: Float, b: Float, out: FloatArray) {