mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +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 android.app.Application
|
||||||
import com.bitchat.android.nostr.RelayDirectory
|
import com.bitchat.android.nostr.RelayDirectory
|
||||||
import com.bitchat.android.ui.theme.ThemePreferenceManager
|
import com.bitchat.android.ui.theme.ThemePreferenceManager
|
||||||
|
import com.bitchat.android.ui.theme.AppSkinPreferenceManager
|
||||||
import com.bitchat.android.net.ArtiTorManager
|
import com.bitchat.android.net.ArtiTorManager
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -37,6 +38,8 @@ class BitchatApplication : Application() {
|
|||||||
|
|
||||||
// Initialize theme preference
|
// Initialize theme preference
|
||||||
ThemePreferenceManager.init(this)
|
ThemePreferenceManager.init(this)
|
||||||
|
// Initialize design-language skin preference (Matrix vs Material Expressive)
|
||||||
|
AppSkinPreferenceManager.init(this)
|
||||||
|
|
||||||
// Initialize debug preference manager (persists debug toggles)
|
// Initialize debug preference manager (persists debug toggles)
|
||||||
try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { }
|
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.TorMode
|
||||||
import com.bitchat.android.net.TorPreferenceManager
|
import com.bitchat.android.net.TorPreferenceManager
|
||||||
import com.bitchat.android.net.ArtiTorManager
|
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
|
* Feature row for displaying app capabilities
|
||||||
@ -90,14 +100,16 @@ private fun ThemeChip(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
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(
|
Surface(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
shape = RoundedCornerShape(10.dp),
|
shape = if (expressive) RoundedCornerShape(20.dp) else RoundedCornerShape(10.dp),
|
||||||
color = if (selected) {
|
color = if (selected) {
|
||||||
if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
if (expressive) colorScheme.secondaryContainer else selectedColor
|
||||||
} else {
|
} else {
|
||||||
colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
}
|
}
|
||||||
@ -105,14 +117,86 @@ private fun ThemeChip(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 10.dp),
|
.padding(vertical = if (expressive) 14.dp else 10.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
|
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
|
statusIndicator: (@Composable () -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
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(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@ -181,9 +267,9 @@ private fun SettingsToggleRow(
|
|||||||
onCheckedChange = { if (enabled) onCheckedChange(it) },
|
onCheckedChange = { if (enabled) onCheckedChange(it) },
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
colors = SwitchDefaults.colors(
|
colors = SwitchDefaults.colors(
|
||||||
checkedThumbColor = Color.White,
|
checkedThumbColor = if (expressive) colorScheme.onPrimary else Color.White,
|
||||||
checkedTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D),
|
checkedTrackColor = trackColor,
|
||||||
uncheckedThumbColor = Color.White,
|
uncheckedThumbColor = if (expressive) colorScheme.outline else Color.White,
|
||||||
uncheckedTrackColor = colorScheme.surfaceVariant
|
uncheckedTrackColor = colorScheme.surfaceVariant
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -225,7 +311,9 @@ fun AboutSheet(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val expressive = isExpressiveSkin()
|
||||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||||
|
val headerFont = if (expressive) FontFamily.Default else FontFamily.Monospace
|
||||||
|
|
||||||
if (isPresented) {
|
if (isPresented) {
|
||||||
BitchatBottomSheet(
|
BitchatBottomSheet(
|
||||||
@ -251,23 +339,23 @@ fun AboutSheet(
|
|||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.app_name),
|
text = stringResource(R.string.app_name),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = headerFont,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontSize = 28.sp,
|
fontSize = if (expressive) 40.sp else 28.sp,
|
||||||
letterSpacing = 1.sp
|
letterSpacing = if (expressive) (-0.5).sp else 1.sp
|
||||||
),
|
),
|
||||||
color = colorScheme.onBackground
|
color = if (expressive) colorScheme.primary else colorScheme.onBackground
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.version_prefix, versionName ?: ""),
|
text = stringResource(R.string.version_prefix, versionName ?: ""),
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = headerFont,
|
||||||
color = colorScheme.onBackground.copy(alpha = 0.5f)
|
color = colorScheme.onBackground.copy(alpha = 0.5f)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.about_tagline),
|
text = stringResource(R.string.about_tagline),
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = headerFont,
|
||||||
color = colorScheme.onBackground.copy(alpha = 0.6f),
|
color = colorScheme.onBackground.copy(alpha = 0.6f),
|
||||||
modifier = Modifier.padding(top = 4.dp)
|
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") {
|
item(key = "appearance") {
|
||||||
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "THEME",
|
text = stringResource(R.string.about_brightness).uppercase(),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = colorScheme.onBackground.copy(alpha = 0.5f),
|
color = colorScheme.onBackground.copy(alpha = 0.5f),
|
||||||
letterSpacing = 0.5.sp,
|
letterSpacing = 0.5.sp,
|
||||||
@ -509,8 +632,8 @@ fun AboutSheet(
|
|||||||
valueRange = 0f..32f,
|
valueRange = 0f..32f,
|
||||||
steps = 31,
|
steps = 31,
|
||||||
colors = SliderDefaults.colors(
|
colors = SliderDefaults.colors(
|
||||||
thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D),
|
thumbColor = if (expressive) colorScheme.primary else (if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)),
|
||||||
activeTrackColor = 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 androidx.compose.ui.unit.sp
|
||||||
import com.bitchat.android.core.ui.utils.singleOrTripleClickable
|
import com.bitchat.android.core.ui.utils.singleOrTripleClickable
|
||||||
import androidx.compose.foundation.Canvas
|
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.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
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -110,6 +117,8 @@ fun NicknameEditor(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val expressive = isExpressiveSkin()
|
||||||
|
val editorFont = if (expressive) FontFamily.Default else FontFamily.Monospace
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
@ -132,8 +141,8 @@ fun NicknameEditor(
|
|||||||
value = value,
|
value = value,
|
||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||||
color = colorScheme.primary,
|
color = if (expressive) colorScheme.onSurface else colorScheme.primary,
|
||||||
fontFamily = FontFamily.Monospace
|
fontFamily = editorFont
|
||||||
),
|
),
|
||||||
cursorBrush = SolidColor(colorScheme.primary),
|
cursorBrush = SolidColor(colorScheme.primary),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
@ -162,27 +171,37 @@ fun PeerCounter(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val expressive = isExpressiveSkin()
|
||||||
|
val accents = LocalThemeAccents.current
|
||||||
|
|
||||||
// Compute channel-aware people count and color (matches iOS logic exactly)
|
// Compute channel-aware people count and color (matches iOS logic exactly)
|
||||||
val (peopleCount, countColor) = when (selectedLocationChannel) {
|
val (peopleCount, countColor) = when (selectedLocationChannel) {
|
||||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||||
// Geohash channel: show geohash participants
|
// Geohash channel: show geohash participants
|
||||||
val count = geohashPeople.size
|
val count = geohashPeople.size
|
||||||
val green = Color(0xFF00C851) // Standard green
|
Pair(count, if (count > 0) accents.location else Color.Gray)
|
||||||
Pair(count, if (count > 0) green else Color.Gray)
|
|
||||||
}
|
}
|
||||||
is com.bitchat.android.geohash.ChannelID.Mesh,
|
is com.bitchat.android.geohash.ChannelID.Mesh,
|
||||||
null -> {
|
null -> {
|
||||||
// Mesh channel: show Bluetooth-connected peers (excluding self)
|
// Mesh channel: show Bluetooth-connected peers (excluding self)
|
||||||
val count = connectedPeers.size
|
val count = connectedPeers.size
|
||||||
val meshBlue = Color(0xFF007AFF) // iOS-style blue for mesh
|
Pair(count, if (isConnected && count > 0) accents.mesh else Color.Gray)
|
||||||
Pair(count, if (isConnected && count > 0) meshBlue 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(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = modifier.clickable { onClick() }.padding(end = 8.dp) // Added right margin to match "bitchat" logo spacing
|
modifier = rowModifier
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Group,
|
imageVector = Icons.Default.Group,
|
||||||
@ -190,7 +209,7 @@ fun PeerCounter(
|
|||||||
is com.bitchat.android.geohash.ChannelID.Location -> stringResource(R.string.cd_geohash_participants)
|
is com.bitchat.android.geohash.ChannelID.Location -> stringResource(R.string.cd_geohash_participants)
|
||||||
else -> stringResource(R.string.cd_connected_peers)
|
else -> stringResource(R.string.cd_connected_peers)
|
||||||
},
|
},
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(if (expressive) 18.dp else 16.dp),
|
||||||
tint = countColor
|
tint = countColor
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
@ -200,16 +219,16 @@ fun PeerCounter(
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = countColor,
|
color = countColor,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
|
|
||||||
if (joinedChannels.isNotEmpty()) {
|
if (joinedChannels.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.channel_count_prefix) + "${joinedChannels.size}",
|
text = stringResource(R.string.channel_count_prefix) + "${joinedChannels.size}",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = if (isConnected) Color(0xFF00C851) else Color.Red,
|
color = if (isConnected) accents.success else accents.danger,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -266,6 +285,9 @@ private fun ChannelHeader(
|
|||||||
onSidebarClick: () -> Unit
|
onSidebarClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val expressive = isExpressiveSkin()
|
||||||
|
val accents = LocalThemeAccents.current
|
||||||
|
val titleColor = if (expressive) colorScheme.primary else accents.self
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
// Back button - positioned all the way to the left with minimal margin
|
// 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
|
// Title - perfectly centered regardless of other elements
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.chat_channel_prefix, channel),
|
text = stringResource(R.string.chat_channel_prefix, channel),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = if (expressive) MaterialTheme.typography.titleLarge else MaterialTheme.typography.titleMedium,
|
||||||
color = Color(0xFFFF9500), // Orange to match input field
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = titleColor,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
.clickable { onSidebarClick() }
|
.clickable { onSidebarClick() }
|
||||||
@ -316,7 +339,7 @@ private fun ChannelHeader(
|
|||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.chat_leave),
|
text = stringResource(R.string.chat_leave),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = Color.Red
|
color = accents.danger
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -334,6 +357,8 @@ private fun MainHeader(
|
|||||||
viewModel: ChatViewModel
|
viewModel: ChatViewModel
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val expressive = isExpressiveSkin()
|
||||||
|
val accents = LocalThemeAccents.current
|
||||||
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||||
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
|
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
|
||||||
val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
|
val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
|
||||||
@ -348,7 +373,9 @@ private fun MainHeader(
|
|||||||
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
|
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = if (expressive) 8.dp else 0.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
@ -356,22 +383,62 @@ private fun MainHeader(
|
|||||||
modifier = Modifier.fillMaxHeight(),
|
modifier = Modifier.fillMaxHeight(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
// Expressive: bold identity avatar (monogram) to the left of the wordmark
|
||||||
text = stringResource(R.string.app_brand),
|
if (expressive) {
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
val initial = nickname.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "@"
|
||||||
color = colorScheme.primary,
|
Box(
|
||||||
modifier = Modifier.singleOrTripleClickable(
|
modifier = Modifier
|
||||||
onSingleClick = onTitleClick,
|
.size(36.dp)
|
||||||
onTripleClick = onTripleTitleClick
|
.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))
|
||||||
Spacer(modifier = Modifier.width(2.dp))
|
|
||||||
|
NicknameEditor(
|
||||||
NicknameEditor(
|
value = nickname,
|
||||||
value = nickname,
|
onValueChange = onNicknameChange
|
||||||
onValueChange = onNicknameChange
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right section with location channels button and peer counter
|
// Right section with location channels button and peer counter
|
||||||
@ -389,7 +456,7 @@ private fun MainHeader(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(16.dp)
|
.size(16.dp)
|
||||||
.clickable { viewModel.openLatestUnreadPrivateChat() },
|
.clickable { viewModel.openLatestUnreadPrivateChat() },
|
||||||
tint = Color(0xFFFF9500)
|
tint = accents.self
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -417,7 +484,7 @@ private fun MainHeader(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
|
imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
|
||||||
contentDescription = stringResource(R.string.cd_toggle_bookmark),
|
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)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -462,22 +529,57 @@ private fun LocationChannelsButton(
|
|||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val expressive = isExpressiveSkin()
|
||||||
|
val accents = LocalThemeAccents.current
|
||||||
|
|
||||||
// Get current channel selection from location manager
|
// Get current channel selection from location manager
|
||||||
val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||||
val teleported by viewModel.isTeleported.collectAsStateWithLifecycle()
|
val teleported by viewModel.isTeleported.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val (badgeText, badgeColor) = when (selectedChannel) {
|
val (badgeText, badgeColor) = when (selectedChannel) {
|
||||||
is com.bitchat.android.geohash.ChannelID.Mesh -> {
|
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 -> {
|
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||||
val geohash = (selectedChannel as com.bitchat.android.geohash.ChannelID.Location).channel.geohash
|
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(
|
Button(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
|||||||
@ -40,6 +40,8 @@ import com.bitchat.android.ui.media.FullScreenImageViewer
|
|||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(viewModel: ChatViewModel) {
|
fun ChatScreen(viewModel: ChatViewModel) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
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 messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||||
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||||
@ -113,7 +115,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(colorScheme.background) // Extend background to fill entire screen including status bar
|
.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
|
// Main content area that responds to keyboard/window insets
|
||||||
Column(
|
Column(
|
||||||
@ -277,20 +279,35 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||||
.windowInsetsPadding(WindowInsets.ime)
|
.windowInsetsPadding(WindowInsets.ime)
|
||||||
) {
|
) {
|
||||||
Surface(
|
if (expressive) {
|
||||||
shape = CircleShape,
|
FloatingActionButton(
|
||||||
color = colorScheme.background,
|
onClick = { forceScrollToBottom = !forceScrollToBottom },
|
||||||
tonalElevation = 3.dp,
|
containerColor = colorScheme.primaryContainer,
|
||||||
shadowElevation = 6.dp,
|
contentColor = colorScheme.onPrimaryContainer,
|
||||||
border = BorderStroke(2.dp, Color(0xFF00C851))
|
shape = CircleShape,
|
||||||
) {
|
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 6.dp)
|
||||||
IconButton(onClick = { forceScrollToBottom = !forceScrollToBottom }) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.ArrowDownward,
|
imageVector = Icons.Filled.ArrowDownward,
|
||||||
contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom),
|
contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom)
|
||||||
tint = Color(0xFF00C851)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} 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()
|
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
|
* iOS-style peer color assignment using djb2 hash algorithm
|
||||||
* Avoids orange (~30°) reserved for self messages
|
* Avoids orange (~30°) reserved for self messages
|
||||||
@ -338,7 +367,10 @@ private fun appendIOSFormattedContent(
|
|||||||
currentUserNickname: String,
|
currentUserNickname: String,
|
||||||
baseColor: Color,
|
baseColor: Color,
|
||||||
isSelf: Boolean,
|
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
|
// iOS-style patterns: allow optional '#abcd' suffix in mentions
|
||||||
val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex()
|
val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex()
|
||||||
@ -442,7 +474,7 @@ private fun appendIOSFormattedContent(
|
|||||||
|
|
||||||
// Check if this mention targets current user
|
// Check if this mention targets current user
|
||||||
val isMentionToMe = mBase == currentUserNickname
|
val isMentionToMe = mBase == currentUserNickname
|
||||||
val mentionColor = if (isMentionToMe) Color(0xFFFF9500) else baseColor
|
val mentionColor = if (isMentionToMe) selfMentionColor else otherMentionColor
|
||||||
|
|
||||||
// "@" symbol
|
// "@" symbol
|
||||||
builder.pushStyle(SpanStyle(
|
builder.pushStyle(SpanStyle(
|
||||||
@ -493,7 +525,7 @@ private fun appendIOSFormattedContent(
|
|||||||
if (type == "geohash") {
|
if (type == "geohash") {
|
||||||
// Style geohash in blue, underlined, and add click annotation
|
// Style geohash in blue, underlined, and add click annotation
|
||||||
builder.pushStyle(SpanStyle(
|
builder.pushStyle(SpanStyle(
|
||||||
color = Color(0xFF007AFF),
|
color = linkColor,
|
||||||
fontSize = BASE_FONT_SIZE.sp,
|
fontSize = BASE_FONT_SIZE.sp,
|
||||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold,
|
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold,
|
||||||
textDecoration = TextDecoration.Underline
|
textDecoration = TextDecoration.Underline
|
||||||
@ -512,7 +544,7 @@ private fun appendIOSFormattedContent(
|
|||||||
} else if (type == "url") {
|
} else if (type == "url") {
|
||||||
// Style URL in blue, underlined, and add click annotation with the raw text
|
// Style URL in blue, underlined, and add click annotation with the raw text
|
||||||
builder.pushStyle(SpanStyle(
|
builder.pushStyle(SpanStyle(
|
||||||
color = Color(0xFF007AFF),
|
color = linkColor,
|
||||||
fontSize = BASE_FONT_SIZE.sp,
|
fontSize = BASE_FONT_SIZE.sp,
|
||||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold,
|
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold,
|
||||||
textDecoration = TextDecoration.Underline
|
textDecoration = TextDecoration.Underline
|
||||||
|
|||||||
@ -38,6 +38,9 @@ import androidx.compose.ui.focus.focusRequester
|
|||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
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.normalizeAmplitudeSample
|
||||||
import com.bitchat.android.features.voice.AudioWaveformExtractor
|
import com.bitchat.android.features.voice.AudioWaveformExtractor
|
||||||
import com.bitchat.android.ui.media.RealtimeScrollingWaveform
|
import com.bitchat.android.ui.media.RealtimeScrollingWaveform
|
||||||
@ -174,6 +177,9 @@ fun MessageInput(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
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 isFocused = remember { mutableStateOf(false) }
|
||||||
val hasText = value.text.isNotBlank() // Check if there's text for send button state
|
val hasText = value.text.isNotBlank() // Check if there's text for send button state
|
||||||
val keyboard = LocalSoftwareKeyboardController.current
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
@ -183,21 +189,32 @@ fun MessageInput(
|
|||||||
var amplitude by remember { mutableStateOf(0) }
|
var amplitude by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
Row(
|
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,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
// Text input with placeholder OR visualizer when recording
|
// Text input with placeholder OR visualizer when recording
|
||||||
Box(
|
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
|
// Always keep the text field mounted to retain focus and avoid IME collapse
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||||
color = colorScheme.primary,
|
color = if (expressive) colorScheme.onSurface else colorScheme.primary,
|
||||||
fontFamily = FontFamily.Monospace
|
fontFamily = inputFont
|
||||||
),
|
),
|
||||||
cursorBrush = SolidColor(if (isRecording) Color.Transparent else colorScheme.primary),
|
cursorBrush = SolidColor(if (isRecording) Color.Transparent else colorScheme.primary),
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||||
@ -220,7 +237,7 @@ fun MessageInput(
|
|||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.type_a_message_placeholder),
|
text = stringResource(R.string.type_a_message_placeholder),
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
fontFamily = FontFamily.Monospace
|
fontFamily = inputFont
|
||||||
),
|
),
|
||||||
color = colorScheme.onSurface.copy(alpha = 0.5f), // Muted grey
|
color = colorScheme.onSurface.copy(alpha = 0.5f), // Muted grey
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
@ -256,7 +273,11 @@ fun MessageInput(
|
|||||||
// Voice and image buttons when no text (only visible in Mesh chat)
|
// Voice and image buttons when no text (only visible in Mesh chat)
|
||||||
if (value.text.isEmpty() && showMediaButtons) {
|
if (value.text.isEmpty() && showMediaButtons) {
|
||||||
// Hold-to-record microphone
|
// 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
|
// Ensure latest values are used when finishing recording
|
||||||
val latestSelectedPeer = rememberUpdatedState(selectedPrivatePeer)
|
val latestSelectedPeer = rememberUpdatedState(selectedPrivatePeer)
|
||||||
@ -316,7 +337,32 @@ fun MessageInput(
|
|||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Send button with enabled/disabled state
|
// 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
|
onClick = { if (hasText) onSend() }, // Only execute if there's text
|
||||||
enabled = hasText, // Enable only when there's text
|
enabled = hasText, // Enable only when there's text
|
||||||
modifier = Modifier.size(32.dp)
|
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.model.BitchatMessageType
|
||||||
import com.bitchat.android.R
|
import com.bitchat.android.R
|
||||||
import androidx.compose.ui.res.stringResource
|
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
|
// VoiceNotePlayer moved to com.bitchat.android.ui.media.VoiceNotePlayer
|
||||||
@ -110,7 +116,7 @@ fun MessagesList(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
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,
|
modifier = modifier,
|
||||||
reverseLayout = true
|
reverseLayout = true
|
||||||
) {
|
) {
|
||||||
@ -178,7 +184,8 @@ fun MessageItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delivery status for private messages (overlay, non-displacing)
|
// 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 ->
|
message.deliveryStatus?.let { status ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -365,7 +372,22 @@ fun MessageItem(
|
|||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
} else {
|
} 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(
|
val annotatedText = formatMessageAsAnnotatedString(
|
||||||
message = message,
|
message = message,
|
||||||
currentUserNickname = currentUserNickname,
|
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
|
@Composable
|
||||||
fun DeliveryStatusIcon(status: DeliveryStatus) {
|
fun DeliveryStatusIcon(status: DeliveryStatus) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
when (status) {
|
when (status) {
|
||||||
is DeliveryStatus.Sending -> {
|
is DeliveryStatus.Sending -> {
|
||||||
Text(
|
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.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalView
|
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)
|
primary = Color(0xFF39FF14), // Bright green (terminal-like)
|
||||||
onPrimary = Color.Black,
|
onPrimary = Color.Black,
|
||||||
secondary = Color(0xFF2ECB10), // Darker green
|
secondary = Color(0xFF2ECB10), // Darker green
|
||||||
@ -30,7 +36,7 @@ private val DarkColorScheme = darkColorScheme(
|
|||||||
onError = Color.Black
|
onError = Color.Black
|
||||||
)
|
)
|
||||||
|
|
||||||
private val LightColorScheme = lightColorScheme(
|
private val MatrixLightColorScheme = lightColorScheme(
|
||||||
primary = Color(0xFF008000), // Dark green
|
primary = Color(0xFF008000), // Dark green
|
||||||
onPrimary = Color.White,
|
onPrimary = Color.White,
|
||||||
secondary = Color(0xFF006600), // Even darker green
|
secondary = Color(0xFF006600), // Even darker green
|
||||||
@ -43,13 +49,29 @@ private val LightColorScheme = lightColorScheme(
|
|||||||
onError = Color.White
|
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
|
@Composable
|
||||||
fun BitchatTheme(
|
fun BitchatTheme(
|
||||||
darkTheme: Boolean? = null,
|
darkTheme: Boolean? = null,
|
||||||
content: @Composable () -> Unit
|
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 themePref by ThemePreferenceManager.themeFlow.collectAsState(initial = ThemePreference.System)
|
||||||
|
|
||||||
val shouldUseDark = when (darkTheme) {
|
val shouldUseDark = when (darkTheme) {
|
||||||
true -> true
|
true -> true
|
||||||
false -> false
|
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
|
val view = LocalView.current
|
||||||
SideEffect {
|
SideEffect {
|
||||||
@ -83,9 +119,15 @@ fun BitchatTheme(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MaterialTheme(
|
CompositionLocalProvider(
|
||||||
colorScheme = colorScheme,
|
LocalAppSkin provides skin,
|
||||||
typography = Typography,
|
LocalThemeAccents provides accents
|
||||||
content = content
|
) {
|
||||||
)
|
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_system">system</string>
|
||||||
<string name="about_light">light</string>
|
<string name="about_light">light</string>
|
||||||
<string name="about_dark">dark</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">proof of work</string>
|
||||||
<string name="about_pow_off">pow off</string>
|
<string name="about_pow_off">pow off</string>
|
||||||
<string name="about_pow_on">pow on</string>
|
<string name="about_pow_on">pow on</string>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user