mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
native
This commit is contained in:
parent
3a983c5767
commit
e2b38ec20e
@ -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) { }
|
||||
|
||||
@ -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))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
63
app/src/main/java/com/bitchat/android/ui/theme/AppSkin.kt
Normal file
63
app/src/main/java/com/bitchat/android/ui/theme/AppSkin.kt
Normal 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
|
||||
@ -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),
|
||||
)
|
||||
@ -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)
|
||||
)
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user