This commit is contained in:
CC 2026-06-17 20:55:59 +02:00
parent 3a983c5767
commit e2b38ec20e
14 changed files with 1094 additions and 97 deletions

View File

@ -3,6 +3,7 @@ package com.bitchat.android
import android.app.Application
import com.bitchat.android.nostr.RelayDirectory
import com.bitchat.android.ui.theme.ThemePreferenceManager
import com.bitchat.android.ui.theme.AppSkinPreferenceManager
import com.bitchat.android.net.ArtiTorManager
/**
@ -37,6 +38,8 @@ class BitchatApplication : Application() {
// Initialize theme preference
ThemePreferenceManager.init(this)
// Initialize design-language skin preference (Matrix vs Material Expressive)
AppSkinPreferenceManager.init(this)
// Initialize debug preference manager (persists debug toggles)
try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { }

View File

@ -35,6 +35,16 @@ import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.net.TorMode
import com.bitchat.android.net.TorPreferenceManager
import com.bitchat.android.net.ArtiTorManager
import com.bitchat.android.ui.theme.AppSkin
import com.bitchat.android.ui.theme.AppSkinPreferenceManager
import com.bitchat.android.ui.theme.LocalAppSkin
import com.bitchat.android.ui.theme.LocalThemeAccents
import com.bitchat.android.ui.theme.isExpressiveSkin
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.filled.Check
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
/**
* Feature row for displaying app capabilities
@ -90,14 +100,16 @@ private fun ThemeChip(
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val expressive = isExpressiveSkin()
val accents = LocalThemeAccents.current
val selectedColor = if (expressive) colorScheme.primary else accents.success
Surface(
modifier = modifier,
onClick = onClick,
shape = RoundedCornerShape(10.dp),
shape = if (expressive) RoundedCornerShape(20.dp) else RoundedCornerShape(10.dp),
color = if (selected) {
if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
if (expressive) colorScheme.secondaryContainer else selectedColor
} else {
colorScheme.surfaceVariant.copy(alpha = 0.5f)
}
@ -105,14 +117,86 @@ private fun ThemeChip(
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
.padding(vertical = if (expressive) 14.dp else 10.dp),
contentAlignment = Alignment.Center
) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.labelLarge,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
color = if (selected) Color.White else colorScheme.onSurface.copy(alpha = 0.8f)
color = when {
selected && expressive -> colorScheme.onSecondaryContainer
selected -> Color.White
else -> colorScheme.onSurface.copy(alpha = 0.8f)
}
)
}
}
}
/**
* Large, bold selectable card used to switch the entire design language (skin).
* Expressive flagship component for the appearance section.
*/
@Composable
private fun SkinCard(
icon: ImageVector,
title: String,
description: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val expressive = isExpressiveSkin()
val shape = if (expressive) RoundedCornerShape(24.dp) else RoundedCornerShape(14.dp)
Surface(
modifier = modifier,
onClick = onClick,
shape = shape,
color = if (selected) colorScheme.primaryContainer else colorScheme.surfaceVariant.copy(alpha = 0.45f),
border = if (selected) BorderStroke(2.dp, colorScheme.primary) else null
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (selected) colorScheme.onPrimaryContainer else colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.size(26.dp)
)
if (selected) {
Surface(color = colorScheme.primary, shape = CircleShape, modifier = Modifier.size(22.dp)) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = colorScheme.onPrimary,
modifier = Modifier.size(15.dp)
)
}
}
}
}
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = if (selected) colorScheme.onPrimaryContainer else colorScheme.onSurface
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = (if (selected) colorScheme.onPrimaryContainer else colorScheme.onSurface).copy(alpha = 0.7f),
lineHeight = 16.sp
)
}
}
@ -133,8 +217,10 @@ private fun SettingsToggleRow(
statusIndicator: (@Composable () -> Unit)? = null
) {
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val expressive = isExpressiveSkin()
val accents = LocalThemeAccents.current
val trackColor = if (expressive) colorScheme.primary else accents.success
Row(
modifier = Modifier
.fillMaxWidth()
@ -181,9 +267,9 @@ private fun SettingsToggleRow(
onCheckedChange = { if (enabled) onCheckedChange(it) },
enabled = enabled,
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D),
uncheckedThumbColor = Color.White,
checkedThumbColor = if (expressive) colorScheme.onPrimary else Color.White,
checkedTrackColor = trackColor,
uncheckedThumbColor = if (expressive) colorScheme.outline else Color.White,
uncheckedTrackColor = colorScheme.surfaceVariant
)
)
@ -225,7 +311,9 @@ fun AboutSheet(
)
val colorScheme = MaterialTheme.colorScheme
val expressive = isExpressiveSkin()
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val headerFont = if (expressive) FontFamily.Default else FontFamily.Monospace
if (isPresented) {
BitchatBottomSheet(
@ -251,23 +339,23 @@ fun AboutSheet(
Text(
text = stringResource(R.string.app_name),
style = TextStyle(
fontFamily = FontFamily.Monospace,
fontFamily = headerFont,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
letterSpacing = 1.sp
fontSize = if (expressive) 40.sp else 28.sp,
letterSpacing = if (expressive) (-0.5).sp else 1.sp
),
color = colorScheme.onBackground
color = if (expressive) colorScheme.primary else colorScheme.onBackground
)
Text(
text = stringResource(R.string.version_prefix, versionName ?: ""),
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
fontFamily = headerFont,
color = colorScheme.onBackground.copy(alpha = 0.5f)
)
Text(
text = stringResource(R.string.about_tagline),
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
fontFamily = headerFont,
color = colorScheme.onBackground.copy(alpha = 0.6f),
modifier = Modifier.padding(top = 4.dp)
)
@ -318,11 +406,46 @@ fun AboutSheet(
}
}
// Appearance Section
// Style (design language / skin) Section
item(key = "style") {
val currentSkin by AppSkinPreferenceManager.skinFlow.collectAsState()
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
Text(
text = stringResource(R.string.about_style).uppercase(),
style = MaterialTheme.typography.labelSmall,
color = colorScheme.onBackground.copy(alpha = 0.5f),
letterSpacing = 0.5.sp,
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
SkinCard(
icon = Icons.Filled.Terminal,
title = stringResource(R.string.theme_matrix),
description = stringResource(R.string.theme_matrix_desc),
selected = currentSkin == AppSkin.MATRIX,
onClick = { AppSkinPreferenceManager.set(context, AppSkin.MATRIX) },
modifier = Modifier.weight(1f)
)
SkinCard(
icon = Icons.Filled.AutoAwesome,
title = stringResource(R.string.theme_expressive),
description = stringResource(R.string.theme_expressive_desc),
selected = currentSkin == AppSkin.EXPRESSIVE,
onClick = { AppSkinPreferenceManager.set(context, AppSkin.EXPRESSIVE) },
modifier = Modifier.weight(1f)
)
}
}
}
// Appearance Section (light / dark within the active skin)
item(key = "appearance") {
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
Text(
text = "THEME",
text = stringResource(R.string.about_brightness).uppercase(),
style = MaterialTheme.typography.labelSmall,
color = colorScheme.onBackground.copy(alpha = 0.5f),
letterSpacing = 0.5.sp,
@ -509,8 +632,8 @@ fun AboutSheet(
valueRange = 0f..32f,
steps = 31,
colors = SliderDefaults.colors(
thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D),
activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
thumbColor = if (expressive) colorScheme.primary else (if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)),
activeTrackColor = if (expressive) colorScheme.primary else (if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D))
)
)

View File

@ -28,7 +28,14 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.core.ui.utils.singleOrTripleClickable
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.style.TextAlign
import com.bitchat.android.ui.theme.LocalThemeAccents
import com.bitchat.android.ui.theme.isExpressiveSkin
import androidx.lifecycle.compose.collectAsStateWithLifecycle
/**
@ -110,6 +117,8 @@ fun NicknameEditor(
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val expressive = isExpressiveSkin()
val editorFont = if (expressive) FontFamily.Default else FontFamily.Monospace
val focusManager = LocalFocusManager.current
val scrollState = rememberScrollState()
@ -132,8 +141,8 @@ fun NicknameEditor(
value = value,
onValueChange = onValueChange,
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = colorScheme.primary,
fontFamily = FontFamily.Monospace
color = if (expressive) colorScheme.onSurface else colorScheme.primary,
fontFamily = editorFont
),
cursorBrush = SolidColor(colorScheme.primary),
singleLine = true,
@ -162,27 +171,37 @@ fun PeerCounter(
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val expressive = isExpressiveSkin()
val accents = LocalThemeAccents.current
// Compute channel-aware people count and color (matches iOS logic exactly)
val (peopleCount, countColor) = when (selectedLocationChannel) {
is com.bitchat.android.geohash.ChannelID.Location -> {
// Geohash channel: show geohash participants
val count = geohashPeople.size
val green = Color(0xFF00C851) // Standard green
Pair(count, if (count > 0) green else Color.Gray)
Pair(count, if (count > 0) accents.location else Color.Gray)
}
is com.bitchat.android.geohash.ChannelID.Mesh,
null -> {
// Mesh channel: show Bluetooth-connected peers (excluding self)
val count = connectedPeers.size
val meshBlue = Color(0xFF007AFF) // iOS-style blue for mesh
Pair(count, if (isConnected && count > 0) meshBlue else Color.Gray)
Pair(count, if (isConnected && count > 0) accents.mesh else Color.Gray)
}
}
val rowModifier = if (expressive) {
modifier
.clip(CircleShape)
.background(colorScheme.surfaceContainerHigh)
.clickable { onClick() }
.padding(horizontal = 12.dp, vertical = 6.dp)
} else {
modifier.clickable { onClick() }.padding(end = 8.dp)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.clickable { onClick() }.padding(end = 8.dp) // Added right margin to match "bitchat" logo spacing
modifier = rowModifier
) {
Icon(
imageVector = Icons.Default.Group,
@ -190,7 +209,7 @@ fun PeerCounter(
is com.bitchat.android.geohash.ChannelID.Location -> stringResource(R.string.cd_geohash_participants)
else -> stringResource(R.string.cd_connected_peers)
},
modifier = Modifier.size(16.dp),
modifier = Modifier.size(if (expressive) 18.dp else 16.dp),
tint = countColor
)
Spacer(modifier = Modifier.width(4.dp))
@ -200,16 +219,16 @@ fun PeerCounter(
style = MaterialTheme.typography.bodyMedium,
color = countColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
fontWeight = FontWeight.SemiBold
)
if (joinedChannels.isNotEmpty()) {
Text(
text = stringResource(R.string.channel_count_prefix) + "${joinedChannels.size}",
style = MaterialTheme.typography.bodyMedium,
color = if (isConnected) Color(0xFF00C851) else Color.Red,
color = if (isConnected) accents.success else accents.danger,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
fontWeight = FontWeight.SemiBold
)
}
}
@ -266,6 +285,9 @@ private fun ChannelHeader(
onSidebarClick: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
val expressive = isExpressiveSkin()
val accents = LocalThemeAccents.current
val titleColor = if (expressive) colorScheme.primary else accents.self
Box(modifier = Modifier.fillMaxWidth()) {
// Back button - positioned all the way to the left with minimal margin
@ -301,8 +323,9 @@ private fun ChannelHeader(
// Title - perfectly centered regardless of other elements
Text(
text = stringResource(R.string.chat_channel_prefix, channel),
style = MaterialTheme.typography.titleMedium,
color = Color(0xFFFF9500), // Orange to match input field
style = if (expressive) MaterialTheme.typography.titleLarge else MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = titleColor,
modifier = Modifier
.align(Alignment.Center)
.clickable { onSidebarClick() }
@ -316,7 +339,7 @@ private fun ChannelHeader(
Text(
text = stringResource(R.string.chat_leave),
style = MaterialTheme.typography.bodySmall,
color = Color.Red
color = accents.danger
)
}
}
@ -334,6 +357,8 @@ private fun MainHeader(
viewModel: ChatViewModel
) {
val colorScheme = MaterialTheme.colorScheme
val expressive = isExpressiveSkin()
val accents = LocalThemeAccents.current
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
@ -348,7 +373,9 @@ private fun MainHeader(
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = if (expressive) 8.dp else 0.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@ -356,22 +383,62 @@ private fun MainHeader(
modifier = Modifier.fillMaxHeight(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.app_brand),
style = MaterialTheme.typography.headlineSmall,
color = colorScheme.primary,
modifier = Modifier.singleOrTripleClickable(
onSingleClick = onTitleClick,
onTripleClick = onTripleTitleClick
// Expressive: bold identity avatar (monogram) to the left of the wordmark
if (expressive) {
val initial = nickname.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "@"
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(accents.self.copy(alpha = 0.18f))
.singleOrTripleClickable(
onSingleClick = onTitleClick,
onTripleClick = onTripleTitleClick
),
contentAlignment = Alignment.Center
) {
Text(
text = initial,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = accents.self
)
}
Spacer(modifier = Modifier.width(10.dp))
Column(verticalArrangement = Arrangement.Center) {
Text(
text = stringResource(R.string.app_brand),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = colorScheme.primary,
modifier = Modifier.singleOrTripleClickable(
onSingleClick = onTitleClick,
onTripleClick = onTripleTitleClick
)
)
NicknameEditor(
value = nickname,
onValueChange = onNicknameChange
)
}
} else {
Text(
text = stringResource(R.string.app_brand),
style = MaterialTheme.typography.headlineSmall,
color = colorScheme.primary,
modifier = Modifier.singleOrTripleClickable(
onSingleClick = onTitleClick,
onTripleClick = onTripleTitleClick
)
)
)
Spacer(modifier = Modifier.width(2.dp))
NicknameEditor(
value = nickname,
onValueChange = onNicknameChange
)
Spacer(modifier = Modifier.width(2.dp))
NicknameEditor(
value = nickname,
onValueChange = onNicknameChange
)
}
}
// Right section with location channels button and peer counter
@ -389,7 +456,7 @@ private fun MainHeader(
modifier = Modifier
.size(16.dp)
.clickable { viewModel.openLatestUnreadPrivateChat() },
tint = Color(0xFFFF9500)
tint = accents.self
)
}
@ -417,7 +484,7 @@ private fun MainHeader(
Icon(
imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
contentDescription = stringResource(R.string.cd_toggle_bookmark),
tint = if (isBookmarked) Color(0xFF00C851) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
tint = if (isBookmarked) accents.success else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
modifier = Modifier.size(16.dp)
)
}
@ -462,22 +529,57 @@ private fun LocationChannelsButton(
onClick: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
val expressive = isExpressiveSkin()
val accents = LocalThemeAccents.current
// Get current channel selection from location manager
val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val teleported by viewModel.isTeleported.collectAsStateWithLifecycle()
val (badgeText, badgeColor) = when (selectedChannel) {
is com.bitchat.android.geohash.ChannelID.Mesh -> {
"#mesh" to Color(0xFF007AFF) // iOS blue for mesh
"#mesh" to accents.mesh
}
is com.bitchat.android.geohash.ChannelID.Location -> {
val geohash = (selectedChannel as com.bitchat.android.geohash.ChannelID.Location).channel.geohash
"#$geohash" to Color(0xFF00C851) // Green for location
"#$geohash" to accents.location
}
null -> "#mesh" to Color(0xFF007AFF) // Default to mesh
null -> "#mesh" to accents.mesh
}
if (expressive) {
// Expressive: a filled tonal chip
Surface(
onClick = onClick,
shape = CircleShape,
color = badgeColor.copy(alpha = 0.16f),
contentColor = badgeColor
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Text(
text = badgeText,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = badgeColor,
maxLines = 1
)
if (teleported) {
Spacer(modifier = Modifier.width(3.dp))
Icon(
imageVector = Icons.Default.PinDrop,
contentDescription = stringResource(R.string.cd_teleported),
modifier = Modifier.size(13.dp),
tint = badgeColor
)
}
}
}
return
}
Button(
onClick = onClick,
colors = ButtonDefaults.buttonColors(

View File

@ -40,6 +40,8 @@ import com.bitchat.android.ui.media.FullScreenImageViewer
@Composable
fun ChatScreen(viewModel: ChatViewModel) {
val colorScheme = MaterialTheme.colorScheme
val expressive = com.bitchat.android.ui.theme.isExpressiveSkin()
val accents = com.bitchat.android.ui.theme.LocalThemeAccents.current
val messages by viewModel.messages.collectAsStateWithLifecycle()
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
@ -113,7 +115,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
.fillMaxSize()
.background(colorScheme.background) // Extend background to fill entire screen including status bar
) {
val headerHeight = 42.dp
val headerHeight = if (expressive) 64.dp else 42.dp
// Main content area that responds to keyboard/window insets
Column(
@ -277,20 +279,35 @@ fun ChatScreen(viewModel: ChatViewModel) {
.windowInsetsPadding(WindowInsets.navigationBars)
.windowInsetsPadding(WindowInsets.ime)
) {
Surface(
shape = CircleShape,
color = colorScheme.background,
tonalElevation = 3.dp,
shadowElevation = 6.dp,
border = BorderStroke(2.dp, Color(0xFF00C851))
) {
IconButton(onClick = { forceScrollToBottom = !forceScrollToBottom }) {
if (expressive) {
FloatingActionButton(
onClick = { forceScrollToBottom = !forceScrollToBottom },
containerColor = colorScheme.primaryContainer,
contentColor = colorScheme.onPrimaryContainer,
shape = CircleShape,
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 6.dp)
) {
Icon(
imageVector = Icons.Filled.ArrowDownward,
contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom),
tint = Color(0xFF00C851)
contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom)
)
}
} else {
Surface(
shape = CircleShape,
color = colorScheme.background,
tonalElevation = 3.dp,
shadowElevation = 6.dp,
border = BorderStroke(2.dp, accents.success)
) {
IconButton(onClick = { forceScrollToBottom = !forceScrollToBottom }) {
Icon(
imageVector = Icons.Filled.ArrowDownward,
contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom),
tint = accents.success
)
}
}
}
}
}

View File

@ -255,6 +255,35 @@ fun formatMessageHeaderAnnotatedString(
return builder.toAnnotatedString()
}
/**
* Build ONLY the message body (no sender prefix, no trailing timestamp) for use inside
* Material Expressive chat bubbles. Colors are supplied by the caller so the text contrasts
* with the bubble container, while mentions/links keep their accent treatment.
*/
fun formatMessageContentAnnotatedString(
message: BitchatMessage,
currentUserNickname: String,
textColor: Color,
linkColor: Color,
mentionColor: Color,
selfMentionColor: Color
): AnnotatedString {
val builder = AnnotatedString.Builder()
appendIOSFormattedContent(
builder = builder,
content = message.content,
mentions = message.mentions,
currentUserNickname = currentUserNickname,
baseColor = textColor,
isSelf = false,
isDark = false,
linkColor = linkColor,
selfMentionColor = selfMentionColor,
otherMentionColor = mentionColor
)
return builder.toAnnotatedString()
}
/**
* iOS-style peer color assignment using djb2 hash algorithm
* Avoids orange (~30°) reserved for self messages
@ -338,7 +367,10 @@ private fun appendIOSFormattedContent(
currentUserNickname: String,
baseColor: Color,
isSelf: Boolean,
isDark: Boolean
isDark: Boolean,
linkColor: Color = Color(0xFF007AFF),
selfMentionColor: Color = Color(0xFFFF9500),
otherMentionColor: Color = baseColor
) {
// iOS-style patterns: allow optional '#abcd' suffix in mentions
val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex()
@ -442,7 +474,7 @@ private fun appendIOSFormattedContent(
// Check if this mention targets current user
val isMentionToMe = mBase == currentUserNickname
val mentionColor = if (isMentionToMe) Color(0xFFFF9500) else baseColor
val mentionColor = if (isMentionToMe) selfMentionColor else otherMentionColor
// "@" symbol
builder.pushStyle(SpanStyle(
@ -493,7 +525,7 @@ private fun appendIOSFormattedContent(
if (type == "geohash") {
// Style geohash in blue, underlined, and add click annotation
builder.pushStyle(SpanStyle(
color = Color(0xFF007AFF),
color = linkColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold,
textDecoration = TextDecoration.Underline
@ -512,7 +544,7 @@ private fun appendIOSFormattedContent(
} else if (type == "url") {
// Style URL in blue, underlined, and add click annotation with the raw text
builder.pushStyle(SpanStyle(
color = Color(0xFF007AFF),
color = linkColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold,
textDecoration = TextDecoration.Underline

View File

@ -38,6 +38,9 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.withStyle
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.theme.LocalThemeAccents
import com.bitchat.android.ui.theme.isExpressiveSkin
import androidx.compose.ui.draw.clip
import com.bitchat.android.features.voice.normalizeAmplitudeSample
import com.bitchat.android.features.voice.AudioWaveformExtractor
import com.bitchat.android.ui.media.RealtimeScrollingWaveform
@ -174,6 +177,9 @@ fun MessageInput(
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val expressive = isExpressiveSkin()
val accents = LocalThemeAccents.current
val inputFont = if (expressive) FontFamily.Default else FontFamily.Monospace
val isFocused = remember { mutableStateOf(false) }
val hasText = value.text.isNotBlank() // Check if there's text for send button state
val keyboard = LocalSoftwareKeyboardController.current
@ -183,21 +189,32 @@ fun MessageInput(
var amplitude by remember { mutableStateOf(0) }
Row(
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), // Reduced padding
modifier = modifier.padding(
horizontal = 12.dp,
vertical = if (expressive) 10.dp else 8.dp
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Text input with placeholder OR visualizer when recording
Box(
modifier = Modifier.weight(1f)
modifier = Modifier
.weight(1f)
.then(
if (expressive) Modifier
.clip(CircleShape)
.background(colorScheme.surfaceContainerHigh)
.padding(horizontal = 18.dp, vertical = 12.dp)
else Modifier
)
) {
// Always keep the text field mounted to retain focus and avoid IME collapse
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = colorScheme.primary,
fontFamily = FontFamily.Monospace
color = if (expressive) colorScheme.onSurface else colorScheme.primary,
fontFamily = inputFont
),
cursorBrush = SolidColor(if (isRecording) Color.Transparent else colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
@ -220,7 +237,7 @@ fun MessageInput(
Text(
text = stringResource(R.string.type_a_message_placeholder),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
fontFamily = inputFont
),
color = colorScheme.onSurface.copy(alpha = 0.5f), // Muted grey
modifier = Modifier.fillMaxWidth()
@ -256,7 +273,11 @@ fun MessageInput(
// Voice and image buttons when no text (only visible in Mesh chat)
if (value.text.isEmpty() && showMediaButtons) {
// Hold-to-record microphone
val bg = if (colorScheme.background == Color.Black) Color(0xFF00FF00).copy(alpha = 0.75f) else Color(0xFF008000).copy(alpha = 0.75f)
val bg = when {
expressive -> colorScheme.primary
colorScheme.background == Color.Black -> Color(0xFF00FF00).copy(alpha = 0.75f)
else -> Color(0xFF008000).copy(alpha = 0.75f)
}
// Ensure latest values are used when finishing recording
val latestSelectedPeer = rememberUpdatedState(selectedPrivatePeer)
@ -316,7 +337,32 @@ fun MessageInput(
} else {
// Send button with enabled/disabled state
IconButton(
if (expressive) {
// Expressive: bold circular send button (FAB-like)
val enabledColor = if (selectedPrivatePeer != null || currentChannel != null) accents.self else colorScheme.primary
IconButton(
onClick = { if (hasText) onSend() },
enabled = hasText,
modifier = Modifier.size(48.dp)
) {
Box(
modifier = Modifier
.size(46.dp)
.background(
color = if (hasText) enabledColor else colorScheme.onSurface.copy(alpha = 0.12f),
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.ArrowUpward,
contentDescription = stringResource(id = R.string.send_message),
modifier = Modifier.size(24.dp),
tint = if (hasText) colorScheme.onPrimary else colorScheme.onSurface.copy(alpha = 0.4f)
)
}
}
} else IconButton(
onClick = { if (hasText) onSend() }, // Only execute if there's text
enabled = hasText, // Enable only when there's text
modifier = Modifier.size(32.dp)

View File

@ -45,6 +45,12 @@ import com.bitchat.android.ui.media.FileMessageItem
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.R
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.draw.clip
import com.bitchat.android.ui.theme.LocalThemeAccents
import com.bitchat.android.ui.theme.isExpressiveSkin
// VoiceNotePlayer moved to com.bitchat.android.ui.media.VoiceNotePlayer
@ -110,7 +116,7 @@ fun MessagesList(
LazyColumn(
state = listState,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(if (isExpressiveSkin()) 8.dp else 4.dp),
modifier = modifier,
reverseLayout = true
) {
@ -178,7 +184,8 @@ fun MessageItem(
}
// Delivery status for private messages (overlay, non-displacing)
if (message.isPrivate && message.sender == currentUserNickname) {
// In Expressive skin the status is rendered inside the bubble footer instead.
if (message.isPrivate && message.sender == currentUserNickname && !isExpressiveSkin()) {
message.deliveryStatus?.let { status ->
Box(
modifier = Modifier
@ -365,7 +372,22 @@ fun MessageItem(
modifier = modifier
)
} else {
// Normal message display
// Expressive skin: render normal text messages as Material 3 chat bubbles
if (isExpressiveSkin()) {
ExpressiveTextBubble(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
modifier = modifier
)
return
}
// Normal message display (Matrix terminal style)
val annotatedText = formatMessageAsAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
@ -464,10 +486,179 @@ fun MessageItem(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ExpressiveTextBubble(
message: BitchatMessage,
currentUserNickname: String,
meshService: MeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier
) {
val accents = LocalThemeAccents.current
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val shortTime = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
// System messages: centered, subtle status pill
if (message.sender == "system") {
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Surface(
shape = CircleShape,
color = colorScheme.surfaceVariant.copy(alpha = 0.45f)
) {
Text(
text = message.content,
style = MaterialTheme.typography.bodySmall,
fontStyle = FontStyle.Italic,
color = colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp)
)
}
}
return
}
val isSelf = message.senderPeerID == meshService.myPeerID ||
message.sender == currentUserNickname ||
message.sender.startsWith("$currentUserNickname#")
val (baseName, suffix) = splitSuffix(message.sender)
val peerColor = if (isSelf) accents.self else getPeerColor(message, isDark)
val bubbleColor = if (isSelf) colorScheme.primaryContainer else colorScheme.surfaceContainerHigh
val contentColor = if (isSelf) colorScheme.onPrimaryContainer else colorScheme.onSurface
val linkColor = if (isSelf) colorScheme.onPrimaryContainer else accents.link
val mentionColor = if (isSelf) colorScheme.onPrimaryContainer else accents.mention
val bubbleShape = if (isSelf) {
RoundedCornerShape(topStart = 22.dp, topEnd = 22.dp, bottomEnd = 6.dp, bottomStart = 22.dp)
} else {
RoundedCornerShape(topStart = 22.dp, topEnd = 22.dp, bottomEnd = 22.dp, bottomStart = 6.dp)
}
val contentText = formatMessageContentAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
textColor = contentColor,
linkColor = linkColor,
mentionColor = mentionColor,
selfMentionColor = accents.self
)
var contentLayout by remember { mutableStateOf<TextLayoutResult?>(null) }
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = if (isSelf) Arrangement.End else Arrangement.Start
) {
Surface(
color = bubbleColor,
shape = bubbleShape,
tonalElevation = if (isSelf) 0.dp else 1.dp,
modifier = Modifier.widthIn(max = 320.dp)
) {
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp)) {
// Sender label for incoming messages
if (!isSelf) {
Text(
text = truncateNickname(baseName) + suffix,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = peerColor,
modifier = Modifier
.padding(bottom = 2.dp)
.clickable(enabled = onNicknameClick != null) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick?.invoke(message.originalSender ?: message.sender)
}
)
}
// Message body with clickable links / geohashes + long-press
Text(
text = contentText,
style = MaterialTheme.typography.bodyLarge.copy(color = contentColor),
modifier = Modifier.pointerInput(message.id) {
detectTapGestures(
onTap = { position ->
val layout = contentLayout ?: return@detectTapGestures
val offset = layout.getOffsetForPosition(position)
contentText.getStringAnnotations("geohash_click", offset, offset).firstOrNull()?.let { ann ->
try {
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(context)
val geohash = ann.item
val level = when (geohash.length) {
in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION
in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE
5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY
6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD
else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK
}
val channel = com.bitchat.android.geohash.GeohashChannel(level, geohash.lowercase())
locationManager.setTeleported(true)
locationManager.select(com.bitchat.android.geohash.ChannelID.Location(channel))
} catch (_: Exception) { }
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
return@detectTapGestures
}
contentText.getStringAnnotations("url_click", offset, offset).firstOrNull()?.let { ann ->
val raw = ann.item
val resolved = if (raw.startsWith("http", ignoreCase = true)) raw else "https://$raw"
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolved))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
} catch (_: Exception) { }
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
},
onLongPress = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onMessageLongPress?.invoke(message)
}
)
},
onTextLayout = { contentLayout = it }
)
// Footer: timestamp, PoW badge, delivery status
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.align(Alignment.End)
.padding(top = 3.dp)
) {
message.powDifficulty?.let { bits ->
if (bits > 0) {
Text(
text = "${bits}b",
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.55f)
)
}
}
Text(
text = shortTime.format(message.timestamp),
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.55f)
)
if (message.isPrivate && isSelf) {
message.deliveryStatus?.let { DeliveryStatusIcon(status = it) }
}
}
}
}
}
}
@Composable
fun DeliveryStatusIcon(status: DeliveryStatus) {
val colorScheme = MaterialTheme.colorScheme
when (status) {
is DeliveryStatus.Sending -> {
Text(

View File

@ -0,0 +1,63 @@
package com.bitchat.android.ui.theme
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.staticCompositionLocalOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Top-level visual "skin" of the app. Unlike [ThemePreference] (which only controls
* light/dark within a skin), this selects the entire design language.
*
* - [MATRIX]: the original terminal-inspired identity monospace type, black canvas,
* phosphor-green accents. bitchat's heritage look.
* - [EXPRESSIVE]: a bold Material 3 Expressive redesign vibrant tonal color
* (dynamic / Material You where available), large rounded shapes, springy motion,
* chat bubbles and emphasized components.
*/
enum class AppSkin {
MATRIX,
EXPRESSIVE;
val isMatrix: Boolean get() = this == MATRIX
val isExpressive: Boolean get() = this == EXPRESSIVE
}
/**
* SharedPreferences-backed manager for the active [AppSkin], mirroring the pattern used by
* [ThemePreferenceManager]. Exposes a [StateFlow] so the whole UI re-composes on change.
*
* Default is [AppSkin.MATRIX] to preserve bitchat's established identity on fresh installs.
*/
object AppSkinPreferenceManager {
private const val PREFS_NAME = "bitchat_settings"
private const val KEY_SKIN = "app_skin"
private val _skinFlow = MutableStateFlow(AppSkin.MATRIX)
val skinFlow: StateFlow<AppSkin> = _skinFlow
fun init(context: Context) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val saved = prefs.getString(KEY_SKIN, AppSkin.MATRIX.name)
_skinFlow.value = runCatching { AppSkin.valueOf(saved!!) }.getOrDefault(AppSkin.MATRIX)
}
fun set(context: Context, skin: AppSkin) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putString(KEY_SKIN, skin.name).apply()
_skinFlow.value = skin
}
}
/**
* The active skin, provided at the root of the composition by [BitchatTheme].
* Any composable can branch on this to deliver a skin-specific layout.
*/
val LocalAppSkin = staticCompositionLocalOf { AppSkin.MATRIX }
/** Convenience: is the Material 3 Expressive skin currently active? */
@Composable
@ReadOnlyComposable
fun isExpressiveSkin(): Boolean = LocalAppSkin.current.isExpressive

View File

@ -0,0 +1,135 @@
package com.bitchat.android.ui.theme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
/**
* Material 3 Expressive brand palette for bitchat.
*
* Used as the fallback when wallpaper-based dynamic color (Material You) is unavailable
* (Android < 12). Tuned to feel energetic and modern: an emerald-green primary that nods
* to bitchat's heritage, a teal secondary, and a warm coral tertiary for expressive accents.
*/
// ---- Light ----
private val ExpPrimaryLight = Color(0xFF006D34)
private val ExpOnPrimaryLight = Color(0xFFFFFFFF)
private val ExpPrimaryContainerLight = Color(0xFF6FFE94)
private val ExpOnPrimaryContainerLight = Color(0xFF00210D)
private val ExpSecondaryLight = Color(0xFF1F6A5E)
private val ExpOnSecondaryLight = Color(0xFFFFFFFF)
private val ExpSecondaryContainerLight = Color(0xFFA7F2E2)
private val ExpOnSecondaryContainerLight = Color(0xFF00201B)
private val ExpTertiaryLight = Color(0xFF9B4434)
private val ExpOnTertiaryLight = Color(0xFFFFFFFF)
private val ExpTertiaryContainerLight = Color(0xFFFFDBD1)
private val ExpOnTertiaryContainerLight = Color(0xFF3A0A02)
private val ExpErrorLight = Color(0xFFBA1A1A)
private val ExpOnErrorLight = Color(0xFFFFFFFF)
private val ExpErrorContainerLight = Color(0xFFFFDAD6)
private val ExpOnErrorContainerLight = Color(0xFF410002)
private val ExpBackgroundLight = Color(0xFFF5FBF3)
private val ExpOnBackgroundLight = Color(0xFF171D18)
private val ExpSurfaceLight = Color(0xFFF5FBF3)
private val ExpOnSurfaceLight = Color(0xFF171D18)
private val ExpSurfaceVariantLight = Color(0xFFDBE5DB)
private val ExpOnSurfaceVariantLight = Color(0xFF404942)
private val ExpOutlineLight = Color(0xFF707972)
private val ExpOutlineVariantLight = Color(0xFFBFC9BF)
val ExpressiveLightColorScheme = lightColorScheme(
primary = ExpPrimaryLight,
onPrimary = ExpOnPrimaryLight,
primaryContainer = ExpPrimaryContainerLight,
onPrimaryContainer = ExpOnPrimaryContainerLight,
secondary = ExpSecondaryLight,
onSecondary = ExpOnSecondaryLight,
secondaryContainer = ExpSecondaryContainerLight,
onSecondaryContainer = ExpOnSecondaryContainerLight,
tertiary = ExpTertiaryLight,
onTertiary = ExpOnTertiaryLight,
tertiaryContainer = ExpTertiaryContainerLight,
onTertiaryContainer = ExpOnTertiaryContainerLight,
error = ExpErrorLight,
onError = ExpOnErrorLight,
errorContainer = ExpErrorContainerLight,
onErrorContainer = ExpOnErrorContainerLight,
background = ExpBackgroundLight,
onBackground = ExpOnBackgroundLight,
surface = ExpSurfaceLight,
onSurface = ExpOnSurfaceLight,
surfaceVariant = ExpSurfaceVariantLight,
onSurfaceVariant = ExpOnSurfaceVariantLight,
outline = ExpOutlineLight,
outlineVariant = ExpOutlineVariantLight,
surfaceTint = ExpPrimaryLight,
surfaceDim = Color(0xFFD5DBD4),
surfaceBright = ExpSurfaceLight,
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFEFF5ED),
surfaceContainer = Color(0xFFE9F0E8),
surfaceContainerHigh = Color(0xFFE3EAE2),
surfaceContainerHighest = Color(0xFFDEE4DC),
)
// ---- Dark ----
private val ExpPrimaryDark = Color(0xFF50E07A)
private val ExpOnPrimaryDark = Color(0xFF003918)
private val ExpPrimaryContainerDark = Color(0xFF005225)
private val ExpOnPrimaryContainerDark = Color(0xFF6FFE94)
private val ExpSecondaryDark = Color(0xFF8BD5C6)
private val ExpOnSecondaryDark = Color(0xFF003730)
private val ExpSecondaryContainerDark = Color(0xFF005046)
private val ExpOnSecondaryContainerDark = Color(0xFFA7F2E2)
private val ExpTertiaryDark = Color(0xFFFFB5A3)
private val ExpOnTertiaryDark = Color(0xFF5D180B)
private val ExpTertiaryContainerDark = Color(0xFF7C2E1F)
private val ExpOnTertiaryContainerDark = Color(0xFFFFDBD1)
private val ExpErrorDark = Color(0xFFFFB4AB)
private val ExpOnErrorDark = Color(0xFF690005)
private val ExpErrorContainerDark = Color(0xFF93000A)
private val ExpOnErrorContainerDark = Color(0xFFFFDAD6)
private val ExpBackgroundDark = Color(0xFF0E1511)
private val ExpOnBackgroundDark = Color(0xFFDEE4DC)
private val ExpSurfaceDark = Color(0xFF0E1511)
private val ExpOnSurfaceDark = Color(0xFFDEE4DC)
private val ExpSurfaceVariantDark = Color(0xFF404942)
private val ExpOnSurfaceVariantDark = Color(0xFFBFC9BF)
private val ExpOutlineDark = Color(0xFF8A938A)
private val ExpOutlineVariantDark = Color(0xFF404942)
val ExpressiveDarkColorScheme = darkColorScheme(
primary = ExpPrimaryDark,
onPrimary = ExpOnPrimaryDark,
primaryContainer = ExpPrimaryContainerDark,
onPrimaryContainer = ExpOnPrimaryContainerDark,
secondary = ExpSecondaryDark,
onSecondary = ExpOnSecondaryDark,
secondaryContainer = ExpSecondaryContainerDark,
onSecondaryContainer = ExpOnSecondaryContainerDark,
tertiary = ExpTertiaryDark,
onTertiary = ExpOnTertiaryDark,
tertiaryContainer = ExpTertiaryContainerDark,
onTertiaryContainer = ExpOnTertiaryContainerDark,
error = ExpErrorDark,
onError = ExpOnErrorDark,
errorContainer = ExpErrorContainerDark,
onErrorContainer = ExpOnErrorContainerDark,
background = ExpBackgroundDark,
onBackground = ExpOnBackgroundDark,
surface = ExpSurfaceDark,
onSurface = ExpOnSurfaceDark,
surfaceVariant = ExpSurfaceVariantDark,
onSurfaceVariant = ExpOnSurfaceVariantDark,
outline = ExpOutlineDark,
outlineVariant = ExpOutlineVariantDark,
surfaceTint = ExpPrimaryDark,
surfaceDim = ExpSurfaceDark,
surfaceBright = Color(0xFF343B36),
surfaceContainerLowest = Color(0xFF090F0C),
surfaceContainerLow = Color(0xFF171D19),
surfaceContainer = Color(0xFF1B211D),
surfaceContainerHigh = Color(0xFF252B27),
surfaceContainerHighest = Color(0xFF303631),
)

View File

@ -0,0 +1,28 @@
package com.bitchat.android.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
/**
* Material 3 Expressive shape scale for bitchat.
*
* Expressive design leans on generous, pill-like rounding. Corner radii are noticeably
* larger than the M3 baseline to give components a soft, friendly, "squircle" character.
*/
val ExpressiveShapes = Shapes(
extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(14.dp),
medium = RoundedCornerShape(20.dp),
large = RoundedCornerShape(28.dp),
extraLarge = RoundedCornerShape(36.dp)
)
/** Baseline (Matrix) shapes — tighter, more utilitarian terminal corners. */
val MatrixShapes = Shapes(
extraSmall = RoundedCornerShape(4.dp),
small = RoundedCornerShape(6.dp),
medium = RoundedCornerShape(8.dp),
large = RoundedCornerShape(12.dp),
extraLarge = RoundedCornerShape(16.dp)
)

View File

@ -0,0 +1,141 @@
package com.bitchat.android.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.sp
/**
* Material 3 Expressive typography for bitchat.
*
* Deliberately the opposite of the Matrix skin's uniform monospace: a friendly sans-serif
* (Roboto / system default) with strong weight contrast and large, confident display sizes.
* Headlines lean bold; body stays highly legible.
*/
private val ExpressiveFont = FontFamily.Default
private val lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Center,
trim = LineHeightStyle.Trim.None
)
val ExpressiveTypography = Typography(
displayLarge = TextStyle(
fontFamily = ExpressiveFont,
fontWeight = FontWeight.Bold,
fontSize = 52.sp,
lineHeight = 58.sp,
letterSpacing = (-0.5).sp,
lineHeightStyle = lineHeightStyle
),
displayMedium = TextStyle(
fontFamily = ExpressiveFont,
fontWeight = FontWeight.Bold,
fontSize = 42.sp,
lineHeight = 48.sp,
letterSpacing = (-0.25).sp,
lineHeightStyle = lineHeightStyle
),
displaySmall = TextStyle(
fontFamily = ExpressiveFont,
fontWeight = FontWeight.Bold,
fontSize = 34.sp,
lineHeight = 40.sp,
lineHeightStyle = lineHeightStyle
),
headlineLarge = TextStyle(
fontFamily = ExpressiveFont,
fontWeight = FontWeight.Bold,
fontSize = 30.sp,
lineHeight = 38.sp,
lineHeightStyle = lineHeightStyle
),
headlineMedium = TextStyle(
fontFamily = ExpressiveFont,
fontWeight = FontWeight.Bold,
fontSize = 26.sp,
lineHeight = 34.sp,
lineHeightStyle = lineHeightStyle
),
headlineSmall = TextStyle(
fontFamily = ExpressiveFont,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 28.sp,
lineHeightStyle = lineHeightStyle
),
titleLarge = TextStyle(
fontFamily = ExpressiveFont,
fontWeight = FontWeight.Bold,
fontSize = 21.sp,
lineHeight = 28.sp,
lineHeightStyle = lineHeightStyle
),
titleMedium = TextStyle(
fontFamily = ExpressiveFont,
fontWeight = FontWeight.SemiBold,
fontSize = 17.sp,
lineHeight = 24.sp,
letterSpacing = 0.1.sp,
lineHeightStyle = lineHeightStyle
),
titleSmall = TextStyle(
fontFamily = ExpressiveFont,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
lineHeightStyle = lineHeightStyle
),
bodyLarge = TextStyle(
fontFamily = ExpressiveFont,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
lineHeightStyle = lineHeightStyle
),
bodyMedium = TextStyle(
fontFamily = ExpressiveFont,
fontWeight = FontWeight.Normal,
fontSize = 15.sp,
lineHeight = 21.sp,
letterSpacing = 0.25.sp,
lineHeightStyle = lineHeightStyle
),
bodySmall = TextStyle(
fontFamily = ExpressiveFont,
fontWeight = FontWeight.Normal,
fontSize = 13.sp,
lineHeight = 18.sp,
letterSpacing = 0.4.sp,
lineHeightStyle = lineHeightStyle
),
labelLarge = TextStyle(
fontFamily = ExpressiveFont,
fontWeight = FontWeight.SemiBold,
fontSize = 15.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
lineHeightStyle = lineHeightStyle
),
labelMedium = TextStyle(
fontFamily = ExpressiveFont,
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
lineHeightStyle = lineHeightStyle
),
labelSmall = TextStyle(
fontFamily = ExpressiveFont,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
lineHeightStyle = lineHeightStyle
)
)

View File

@ -7,17 +7,23 @@ import android.view.WindowInsetsController
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
// Colors that match the iOS bitchat theme
private val DarkColorScheme = darkColorScheme(
// ============================================================================
// MATRIX skin — the original terminal-inspired identity (iOS parity)
// ============================================================================
private val MatrixDarkColorScheme = darkColorScheme(
primary = Color(0xFF39FF14), // Bright green (terminal-like)
onPrimary = Color.Black,
secondary = Color(0xFF2ECB10), // Darker green
@ -30,7 +36,7 @@ private val DarkColorScheme = darkColorScheme(
onError = Color.Black
)
private val LightColorScheme = lightColorScheme(
private val MatrixLightColorScheme = lightColorScheme(
primary = Color(0xFF008000), // Dark green
onPrimary = Color.White,
secondary = Color(0xFF006600), // Even darker green
@ -43,13 +49,29 @@ private val LightColorScheme = lightColorScheme(
onError = Color.White
)
/**
* Root theme for bitchat. Resolves two independent axes:
*
* 1. [AppSkin] (via [AppSkinPreferenceManager]) the entire design language
* (Matrix terminal vs. Material 3 Expressive).
* 2. light/dark (via [ThemePreferenceManager], or [darkTheme] override) applied within a skin.
*
* For the Expressive skin we prefer wallpaper-based dynamic color (Material You) on Android 12+,
* falling back to the bitchat brand palette below that.
*
* The resolved [AppSkin] and [ThemeAccents] are published via composition locals so any
* composable can branch its layout and pull semantic accent colors centrally.
*/
@Composable
fun BitchatTheme(
darkTheme: Boolean? = null,
content: @Composable () -> Unit
) {
// App-level override from ThemePreferenceManager
val context = LocalContext.current
val skin by AppSkinPreferenceManager.skinFlow.collectAsState(initial = AppSkin.MATRIX)
val themePref by ThemePreferenceManager.themeFlow.collectAsState(initial = ThemePreference.System)
val shouldUseDark = when (darkTheme) {
true -> true
false -> false
@ -60,7 +82,21 @@ fun BitchatTheme(
}
}
val colorScheme = if (shouldUseDark) DarkColorScheme else LightColorScheme
val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val colorScheme = when (skin) {
AppSkin.EXPRESSIVE -> when {
dynamicColorAvailable && shouldUseDark -> dynamicDarkColorScheme(context)
dynamicColorAvailable -> dynamicLightColorScheme(context)
shouldUseDark -> ExpressiveDarkColorScheme
else -> ExpressiveLightColorScheme
}
AppSkin.MATRIX -> if (shouldUseDark) MatrixDarkColorScheme else MatrixLightColorScheme
}
val typography = if (skin.isExpressive) ExpressiveTypography else Typography
val shapes = if (skin.isExpressive) ExpressiveShapes else MatrixShapes
val accents = if (skin.isExpressive) expressiveAccents(colorScheme, shouldUseDark) else MatrixAccents
val view = LocalView.current
SideEffect {
@ -83,9 +119,15 @@ fun BitchatTheme(
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
CompositionLocalProvider(
LocalAppSkin provides skin,
LocalThemeAccents provides accents
) {
MaterialTheme(
colorScheme = colorScheme,
typography = typography,
shapes = shapes,
content = content
)
}
}

View File

@ -0,0 +1,68 @@
package com.bitchat.android.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
/**
* Centralized, skin-aware semantic accent colors.
*
* Historically these meanings (mesh = iOS blue, location = green, "you" = orange, etc.) were
* hardcoded as `Color(0xFF...)` literals scattered across ~100 call sites. This consolidates
* them into one provided object so each skin can express the same *meaning* in its own language:
*
* - In [AppSkin.MATRIX] they keep the original terminal accent values (iOS parity).
* - In [AppSkin.EXPRESSIVE] they derive from the (possibly dynamic) Material color scheme so
* accents harmonize with the user's wallpaper-based palette.
*/
data class ThemeAccents(
/** "You" — your own messages, your mentions, the active identity. */
val self: Color,
/** Bluetooth mesh transport. */
val mesh: Color,
/** Geohash / location channels. */
val location: Color,
/** Established end-to-end encryption (lock). */
val secure: Color,
/** Tappable links: URLs and geohash references. */
val link: Color,
/** Positive / connected status. */
val success: Color,
/** Caution / in-progress status. */
val warning: Color,
/** Error / disconnected / destructive. */
val danger: Color,
/** @mentions of other people. */
val mention: Color
)
/** Original terminal accent palette (matches the iOS build). */
val MatrixAccents = ThemeAccents(
self = Color(0xFFFF9500),
mesh = Color(0xFF007AFF),
location = Color(0xFF00C851),
secure = Color(0xFFFF9500),
link = Color(0xFF007AFF),
success = Color(0xFF00C851),
warning = Color(0xFFFF9500),
danger = Color(0xFFFF3B30),
mention = Color(0xFFFF9500)
)
/**
* Derive Expressive accents from the active Material color scheme so they stay coherent with
* dynamic color. We still keep semantically conventional hues for status (green/amber/red).
*/
fun expressiveAccents(colorScheme: ColorScheme, dark: Boolean): ThemeAccents = ThemeAccents(
self = colorScheme.tertiary,
mesh = colorScheme.primary,
location = colorScheme.secondary,
secure = colorScheme.tertiary,
link = colorScheme.primary,
success = if (dark) Color(0xFF7CDB8E) else Color(0xFF1E7A3C),
warning = if (dark) Color(0xFFF2C14E) else Color(0xFFB07900),
danger = colorScheme.error,
mention = colorScheme.tertiary
)
val LocalThemeAccents = staticCompositionLocalOf { MatrixAccents }

View File

@ -115,6 +115,12 @@
<string name="about_system">system</string>
<string name="about_light">light</string>
<string name="about_dark">dark</string>
<string name="about_style">style</string>
<string name="about_brightness">brightness</string>
<string name="theme_matrix">Matrix</string>
<string name="theme_matrix_desc">Terminal · monospace · phosphor green</string>
<string name="theme_expressive">Material Expressive</string>
<string name="theme_expressive_desc">Bold · dynamic color · rounded</string>
<string name="about_pow">proof of work</string>
<string name="about_pow_off">pow off</string>
<string name="about_pow_on">pow on</string>