This commit is contained in:
callebtc 2026-07-27 16:06:22 +02:00
parent c1c7704c32
commit 25e9779f73
27 changed files with 415 additions and 381 deletions

View File

@ -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
)
) {

View File

@ -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,

View File

@ -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(

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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)
}

View File

@ -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<GeoPerson>,
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(

View File

@ -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<WebView?>(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()) {

View File

@ -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

View File

@ -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(

View File

@ -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
)
}
}

View File

@ -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
)
)
}

View File

@ -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(

View File

@ -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
}
)
}

View File

@ -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) }

View File

@ -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)

View File

@ -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)
}

View File

@ -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(

View File

@ -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()

View File

@ -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) {

View File

@ -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

View File

@ -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 }

View File

@ -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
)
}

View File

@ -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

View File

@ -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) {