diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index 32f11d0b..5d885099 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -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) { } diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index f137ac63..3afd8f54 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -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)) ) ) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 7273813b..b5db58e4 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -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( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index bc83a9f9..0742a762 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -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 + ) + } + } } } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index e3097eed..21ffd720 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index 91555b38..dda44623 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -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) diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 88b24015..86e1ca31 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -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(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( diff --git a/app/src/main/java/com/bitchat/android/ui/theme/AppSkin.kt b/app/src/main/java/com/bitchat/android/ui/theme/AppSkin.kt new file mode 100644 index 00000000..b9efd08a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/AppSkin.kt @@ -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 = _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 diff --git a/app/src/main/java/com/bitchat/android/ui/theme/ExpressiveColors.kt b/app/src/main/java/com/bitchat/android/ui/theme/ExpressiveColors.kt new file mode 100644 index 00000000..1b528719 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/ExpressiveColors.kt @@ -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), +) diff --git a/app/src/main/java/com/bitchat/android/ui/theme/ExpressiveShapes.kt b/app/src/main/java/com/bitchat/android/ui/theme/ExpressiveShapes.kt new file mode 100644 index 00000000..aa5cce1e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/ExpressiveShapes.kt @@ -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) +) diff --git a/app/src/main/java/com/bitchat/android/ui/theme/ExpressiveTypography.kt b/app/src/main/java/com/bitchat/android/ui/theme/ExpressiveTypography.kt new file mode 100644 index 00000000..afcd4af9 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/ExpressiveTypography.kt @@ -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 + ) +) diff --git a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt index 76ef3130..a573527b 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt @@ -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 + ) + } } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/ThemeAccents.kt b/app/src/main/java/com/bitchat/android/ui/theme/ThemeAccents.kt new file mode 100644 index 00000000..0edfec46 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/ThemeAccents.kt @@ -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 } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 27a5c5e5..8a468acd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -115,6 +115,12 @@ system light dark + style + brightness + Matrix + Terminal · monospace · phosphor green + Material Expressive + Bold · dynamic color · rounded proof of work pow off pow on