mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
first pass
This commit is contained in:
parent
92d07b22fa
commit
e1d07f99f5
@ -63,7 +63,9 @@ fun BitChatBrandButton(
|
||||
imageVector = BitChatIcon,
|
||||
contentDescription = contentDescription,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(16.dp),
|
||||
// 20.dp rather than 16.dp: the pixel-art bubble has heavy internal negative space,
|
||||
// so it reads noticeably smaller than a solid glyph at the same nominal size.
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
622
app/src/main/java/com/bitchat/android/ui/AboutSections.kt
Normal file
622
app/src/main/java/com/bitchat/android/ui/AboutSections.kt
Normal file
@ -0,0 +1,622 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AlternateEmail
|
||||
import androidx.compose.material.icons.filled.DeleteForever
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material.icons.filled.WifiOff
|
||||
import androidx.compose.material.icons.outlined.BookmarkBorder
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.Public
|
||||
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.icon.BitChatIcon
|
||||
import com.bitchat.android.ui.theme.BitchatMotion
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
|
||||
/**
|
||||
* Building blocks for the redesigned About sheet.
|
||||
*
|
||||
* Kept in a separate file from [AboutSheet] because the sheet itself is mostly wiring for
|
||||
* preferences, whereas these are pure presentation.
|
||||
*/
|
||||
|
||||
/** Horizontal inset shared by every About section, so cards and labels align to one grid. */
|
||||
internal val AboutHorizontalPadding = 20.dp
|
||||
|
||||
/** Card corner radius for grouped rows. */
|
||||
internal val AboutCardShape = RoundedCornerShape(16.dp)
|
||||
|
||||
/** Two top-level views of the sheet. */
|
||||
enum class AboutTab {
|
||||
HowToUse,
|
||||
BasicInfo,
|
||||
}
|
||||
|
||||
/**
|
||||
* Small uppercase section label, e.g. `SETTINGS`.
|
||||
*
|
||||
* Uppercasing happens here rather than in the string resource so translators supply natural
|
||||
* sentence case and locales without a case distinction are unaffected.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AboutSectionLabel(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
letterSpacing = 0.8.sp,
|
||||
color = palette.textTertiary,
|
||||
modifier = modifier.padding(start = AboutHorizontalPadding, top = 24.dp, bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered app identity block: logo, wordmark, tagline, version.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AboutHero(
|
||||
versionName: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = BitChatIcon,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier.size(64.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 40.sp,
|
||||
// Monospace at display size leaves too much air between glyphs; pull it in slightly
|
||||
// so the wordmark reads as a single unit.
|
||||
letterSpacing = (-0.5).sp,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.about_tagline),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 16.sp,
|
||||
color = palette.textSecondary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.version_prefix, versionName),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = palette.textTertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-up tab bar with a sliding underline indicator.
|
||||
*
|
||||
* The indicator animates its offset rather than cross-fading two static bars, which is what
|
||||
* makes the switch feel physically connected to the tap.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AboutTabBar(
|
||||
selected: AboutTab,
|
||||
onSelect: (AboutTab) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val density = LocalDensity.current
|
||||
|
||||
var rowWidth by remember { mutableStateOf(0.dp) }
|
||||
val tabWidth = rowWidth / 2
|
||||
val indicatorOffset by animateDpAsState(
|
||||
targetValue = if (selected == AboutTab.HowToUse) 0.dp else tabWidth,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "aboutTabIndicator"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding)
|
||||
.onSizeChanged { size ->
|
||||
rowWidth = with(density) { size.width.toDp() }
|
||||
}
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
AboutTabLabel(
|
||||
text = stringResource(R.string.about_tab_how_to_use),
|
||||
isSelected = selected == AboutTab.HowToUse,
|
||||
onClick = { onSelect(AboutTab.HowToUse) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
AboutTabLabel(
|
||||
text = stringResource(R.string.about_tab_basic_info),
|
||||
isSelected = selected == AboutTab.BasicInfo,
|
||||
onClick = { onSelect(AboutTab.BasicInfo) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(x = indicatorOffset)
|
||||
.width(tabWidth)
|
||||
.height(2.dp)
|
||||
.background(colorScheme.primary)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutTabLabel(
|
||||
text: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
val color by animateColorAsState(
|
||||
targetValue = if (isSelected) colorScheme.primary else palette.textSecondary,
|
||||
animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing),
|
||||
label = "aboutTabLabelColor"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(44.dp)
|
||||
.clickable(onClickLabel = text) { onClick() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
letterSpacing = 0.8.sp,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One line of the "How To Use" list: an icon plus a single instruction. */
|
||||
@Composable
|
||||
private fun AboutInstructionRow(
|
||||
icon: ImageVector,
|
||||
text: String
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.size(18.dp)
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
color = palette.textPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The "How To Use" tab: a short, scannable list of the gestures that are not self-evident.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AboutHowToUseSection(modifier: Modifier = Modifier) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(R.string.about_how_to_use_heading),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.primary,
|
||||
modifier = Modifier.padding(
|
||||
start = AboutHorizontalPadding,
|
||||
top = 20.dp,
|
||||
bottom = 8.dp
|
||||
)
|
||||
)
|
||||
|
||||
AboutInstructionRow(
|
||||
icon = Icons.Filled.AlternateEmail,
|
||||
text = stringResource(R.string.about_howto_nickname)
|
||||
)
|
||||
AboutInstructionRow(
|
||||
icon = Icons.Outlined.Public,
|
||||
text = stringResource(R.string.about_howto_channels)
|
||||
)
|
||||
AboutInstructionRow(
|
||||
icon = Icons.Filled.Groups,
|
||||
text = stringResource(R.string.about_howto_people)
|
||||
)
|
||||
AboutInstructionRow(
|
||||
icon = Icons.Outlined.BookmarkBorder,
|
||||
text = stringResource(R.string.about_howto_bookmark)
|
||||
)
|
||||
AboutInstructionRow(
|
||||
icon = Icons.Filled.AlternateEmail,
|
||||
text = stringResource(R.string.about_howto_mention)
|
||||
)
|
||||
AboutInstructionRow(
|
||||
icon = Icons.Filled.Terminal,
|
||||
text = stringResource(R.string.about_howto_commands)
|
||||
)
|
||||
AboutInstructionRow(
|
||||
icon = Icons.Filled.DeleteForever,
|
||||
text = stringResource(R.string.about_howto_panic)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The six-item capability list from the design.
|
||||
*
|
||||
* Icons are Material approximations of the designer's custom line art; swap in the exported
|
||||
* SVGs when they are available.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AboutFeatureCard(modifier: Modifier = Modifier) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding),
|
||||
color = palette.surface,
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Column {
|
||||
val features = listOf(
|
||||
Triple(
|
||||
Icons.Filled.WifiOff,
|
||||
R.string.about_offline_comm_title,
|
||||
R.string.about_offline_comm_desc
|
||||
),
|
||||
Triple(
|
||||
Icons.Outlined.Lock,
|
||||
R.string.about_e2e_title,
|
||||
R.string.about_e2e_desc
|
||||
),
|
||||
Triple(
|
||||
Icons.Outlined.Public,
|
||||
R.string.about_local_channels_title,
|
||||
R.string.about_local_channels_desc
|
||||
),
|
||||
Triple(
|
||||
Icons.Outlined.VisibilityOff,
|
||||
R.string.about_no_tracking_title,
|
||||
R.string.about_no_tracking_desc
|
||||
),
|
||||
Triple(
|
||||
Icons.Filled.Shuffle,
|
||||
R.string.about_ephemeral_title,
|
||||
R.string.about_ephemeral_desc
|
||||
),
|
||||
Triple(
|
||||
Icons.Filled.DeleteForever,
|
||||
R.string.about_panic_title,
|
||||
R.string.about_panic_desc
|
||||
),
|
||||
)
|
||||
|
||||
features.forEachIndexed { index, (icon, titleRes, descRes) ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider(
|
||||
// Inset to align with the text column, not the icon.
|
||||
modifier = Modifier.padding(start = 54.dp),
|
||||
thickness = 1.dp,
|
||||
color = palette.outlineVariant
|
||||
)
|
||||
}
|
||||
AboutFeatureRow(
|
||||
icon = icon,
|
||||
title = stringResource(titleRes),
|
||||
subtitle = stringResource(descRes)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutFeatureRow(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 1.dp)
|
||||
.size(22.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = palette.textPrimary
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 17.sp,
|
||||
color = palette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Security-audit warning banner.
|
||||
*
|
||||
* Deliberately styled as a tinted, outlined card rather than plain red text: it needs to be
|
||||
* impossible to skim past, but it is also permanent until the audit lands, so a full-bleed
|
||||
* alarm would quickly become wallpaper.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AboutWarningCard(modifier: Modifier = Modifier) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding),
|
||||
color = palette.accentRed.copy(alpha = 0.12f),
|
||||
border = BorderStroke(1.dp, palette.accentRed.copy(alpha = 0.35f)),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = palette.accentRed,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.about_warning_title).uppercase(),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.8.sp,
|
||||
color = palette.accentRed
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.about_warning_body),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 17.sp,
|
||||
color = palette.accentRed.copy(alpha = 0.85f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small uppercase pill, e.g. the `RECOMMENDED` badge beside the Tor routing toggle.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BitchatBadge(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(palette.accentGreen.copy(alpha = 0.15f), RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 5.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.5.sp,
|
||||
color = palette.accentGreen
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared bottom-sheet primitives
|
||||
|
||||
/** Horizontal inset for sheet content. Cards inset from here; labels align to the same edge. */
|
||||
internal val SheetHorizontalPadding = 16.dp
|
||||
|
||||
/**
|
||||
* Section divider label used across the sheets, e.g. `BOOKMARKED`, `NEARBY`, `ON LOCATION`.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SheetSectionLabel(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
letterSpacing = 0.8.sp,
|
||||
color = palette.textTertiary,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = SheetHorizontalPadding + 14.dp,
|
||||
end = SheetHorizontalPadding,
|
||||
top = 20.dp,
|
||||
bottom = 6.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Circular tinted badge holding a section's icon, used at the top of the sheets.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SheetHeaderBadge(
|
||||
icon: ImageVector,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val palette = LocalBitchatPalette.current
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(44.dp)
|
||||
.background(palette.surface, androidx.compose.foundation.shape.CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-width destructive action, e.g. `REMOVE LOCATION ACCESS`.
|
||||
*
|
||||
* Tinted fill plus an outline rather than a solid red button: the action is legitimate but
|
||||
* rarely wanted, and a solid red block would dominate the sheet.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SheetDestructiveButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
isDestructive: Boolean = true
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val accent = if (isDestructive) palette.accentRed else palette.accentGreen
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(44.dp),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = accent.copy(alpha = 0.10f),
|
||||
border = BorderStroke(1.dp, accent.copy(alpha = 0.30f))
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
letterSpacing = 0.8.sp,
|
||||
color = accent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@ -35,6 +38,8 @@ 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.BitchatMotion
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
|
||||
/**
|
||||
* Feature row for displaying app capabilities
|
||||
@ -46,11 +51,12 @@ private fun FeatureRow(
|
||||
subtitle: String
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
@ -58,22 +64,24 @@ private fun FeatureRow(
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.padding(top = 1.dp)
|
||||
.size(22.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.onSurface
|
||||
color = palette.textPrimary
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
lineHeight = 18.sp
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = palette.textSecondary,
|
||||
lineHeight = 17.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -89,18 +97,26 @@ private fun ThemeChip(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
// Cross-fade the chip so switching theme does not read as two separate flashes (the chip
|
||||
// recolouring plus the whole app recolouring underneath it).
|
||||
val containerColor by animateColorAsState(
|
||||
targetValue = if (selected) palette.accentGreen else palette.surfaceVariant,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "themeChipContainer"
|
||||
)
|
||||
val labelColor by animateColorAsState(
|
||||
targetValue = if (selected) Color.White else palette.textSecondary,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "themeChipLabel"
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = if (selected) {
|
||||
if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||
} else {
|
||||
colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
}
|
||||
color = containerColor
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@ -110,9 +126,10 @@ private fun ThemeChip(
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = if (selected) Color.White else colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
color = labelColor
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -133,23 +150,23 @@ 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 palette = LocalBitchatPalette.current
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = if (enabled) colorScheme.primary else colorScheme.onSurface.copy(alpha = 0.3f),
|
||||
tint = if (enabled) colorScheme.primary else palette.textTertiary,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
@ -160,31 +177,33 @@ private fun SettingsToggleRow(
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (enabled) colorScheme.onSurface else colorScheme.onSurface.copy(alpha = 0.4f)
|
||||
color = if (enabled) palette.textPrimary else palette.textTertiary
|
||||
)
|
||||
statusIndicator?.invoke()
|
||||
}
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = colorScheme.onSurface.copy(alpha = if (enabled) 0.6f else 0.3f),
|
||||
lineHeight = 16.sp
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = if (enabled) palette.textSecondary else palette.textTertiary,
|
||||
lineHeight = 17.sp
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = { if (enabled) onCheckedChange(it) },
|
||||
enabled = enabled,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = Color.White,
|
||||
checkedTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D),
|
||||
checkedTrackColor = palette.accentGreen,
|
||||
uncheckedThumbColor = Color.White,
|
||||
uncheckedTrackColor = colorScheme.surfaceVariant
|
||||
uncheckedTrackColor = palette.surfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -221,12 +240,15 @@ fun AboutSheet(
|
||||
}
|
||||
val topBarAlpha by animateFloatAsState(
|
||||
targetValue = if (isScrolled) 0.98f else 0f,
|
||||
animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing),
|
||||
label = "topBarAlpha"
|
||||
)
|
||||
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
val palette = LocalBitchatPalette.current
|
||||
val isDark = palette.isDark
|
||||
var selectedTab by remember { mutableStateOf(AboutTab.HowToUse) }
|
||||
|
||||
if (isPresented) {
|
||||
BitchatBottomSheet(
|
||||
modifier = modifier,
|
||||
@ -236,103 +258,48 @@ fun AboutSheet(
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(top = 80.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp)
|
||||
) {
|
||||
// Header Section - App Identity
|
||||
item(key = "header") {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
letterSpacing = 1.sp
|
||||
),
|
||||
color = colorScheme.onBackground
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.version_prefix, versionName ?: ""),
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.5f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.about_tagline),
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.6f),
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
item(key = "hero") {
|
||||
AboutHero(versionName = versionName ?: "")
|
||||
}
|
||||
|
||||
item(key = "tabs") {
|
||||
AboutTabBar(
|
||||
selected = selectedTab,
|
||||
onSelect = { selectedTab = it },
|
||||
modifier = Modifier.padding(top = 24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedTab == AboutTab.HowToUse) {
|
||||
item(key = "how_to_use") {
|
||||
AboutHowToUseSection()
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedTab == AboutTab.BasicInfo) {
|
||||
// Features Section - Grouped Card
|
||||
item(key = "features") {
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.about_appearance).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)
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column {
|
||||
FeatureRow(
|
||||
icon = Icons.Filled.Bluetooth,
|
||||
title = stringResource(R.string.about_offline_mesh_title),
|
||||
subtitle = stringResource(R.string.about_offline_mesh_desc)
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 56.dp),
|
||||
color = colorScheme.outline.copy(alpha = 0.12f)
|
||||
)
|
||||
FeatureRow(
|
||||
icon = Icons.Default.Public,
|
||||
title = stringResource(R.string.about_online_geohash_title),
|
||||
subtitle = stringResource(R.string.about_online_geohash_desc)
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 56.dp),
|
||||
color = colorScheme.outline.copy(alpha = 0.12f)
|
||||
)
|
||||
FeatureRow(
|
||||
icon = Icons.Default.Lock,
|
||||
title = stringResource(R.string.about_e2e_title),
|
||||
subtitle = stringResource(R.string.about_e2e_desc)
|
||||
)
|
||||
}
|
||||
}
|
||||
Column {
|
||||
AboutSectionLabel(text = stringResource(R.string.about_section_about))
|
||||
AboutFeatureCard()
|
||||
}
|
||||
}
|
||||
|
||||
// Appearance Section
|
||||
item(key = "appearance") {
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||
Text(
|
||||
text = "THEME",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.5f),
|
||||
letterSpacing = 0.5.sp,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
Column {
|
||||
AboutSectionLabel(text = stringResource(R.string.about_section_theme))
|
||||
val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState()
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding),
|
||||
color = palette.surface,
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@ -374,18 +341,14 @@ fun AboutSheet(
|
||||
val torStatus by torProvider.statusFlow.collectAsState()
|
||||
val torAvailable = remember { torProvider.isTorAvailable() }
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||
Text(
|
||||
text = "SETTINGS",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.5f),
|
||||
letterSpacing = 0.5.sp,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
Column {
|
||||
AboutSectionLabel(text = stringResource(R.string.about_section_settings))
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding),
|
||||
color = palette.surface,
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Column {
|
||||
// Background Mode Toggle
|
||||
@ -404,12 +367,13 @@ fun AboutSheet(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 56.dp),
|
||||
color = colorScheme.outline.copy(alpha = 0.12f)
|
||||
modifier = Modifier.padding(start = 54.dp),
|
||||
thickness = 1.dp,
|
||||
color = palette.outlineVariant
|
||||
)
|
||||
|
||||
|
||||
// Proof of Work Toggle
|
||||
SettingsToggleRow(
|
||||
icon = Icons.Filled.Speed,
|
||||
@ -418,16 +382,17 @@ fun AboutSheet(
|
||||
checked = powEnabled,
|
||||
onCheckedChange = { PoWPreferenceManager.setPowEnabled(it) }
|
||||
)
|
||||
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 56.dp),
|
||||
color = colorScheme.outline.copy(alpha = 0.12f)
|
||||
modifier = Modifier.padding(start = 54.dp),
|
||||
thickness = 1.dp,
|
||||
color = palette.outlineVariant
|
||||
)
|
||||
|
||||
|
||||
// Tor Toggle
|
||||
SettingsToggleRow(
|
||||
icon = Icons.Filled.Security,
|
||||
title = "Tor Network",
|
||||
title = stringResource(R.string.about_tor_title),
|
||||
subtitle = stringResource(R.string.about_tor_route),
|
||||
checked = torMode.value == TorMode.ON,
|
||||
onCheckedChange = { enabled ->
|
||||
@ -440,9 +405,9 @@ fun AboutSheet(
|
||||
statusIndicator = if (torMode.value == TorMode.ON) {
|
||||
{
|
||||
val statusColor = when {
|
||||
torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||
torStatus.running -> Color(0xFFFF9500)
|
||||
else -> Color(0xFFFF3B30)
|
||||
torStatus.running && torStatus.bootstrapPercent >= 100 -> palette.accentGreen
|
||||
torStatus.running -> palette.accentOrange
|
||||
else -> palette.accentRed
|
||||
}
|
||||
Surface(
|
||||
color = statusColor,
|
||||
@ -454,15 +419,18 @@ fun AboutSheet(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Tor unavailable hint
|
||||
if (!torAvailable) {
|
||||
Text(
|
||||
text = stringResource(R.string.tor_not_available_in_this_build),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.5f),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp)
|
||||
color = palette.textTertiary,
|
||||
modifier = Modifier.padding(
|
||||
start = AboutHorizontalPadding + 16.dp,
|
||||
top = 8.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -472,13 +440,15 @@ fun AboutSheet(
|
||||
item(key = "pow_slider") {
|
||||
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState()
|
||||
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState()
|
||||
|
||||
|
||||
if (powEnabled) {
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding),
|
||||
color = palette.surface,
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@ -490,30 +460,36 @@ fun AboutSheet(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Difficulty",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
text = stringResource(R.string.about_difficulty),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.onSurface
|
||||
color = palette.textPrimary
|
||||
)
|
||||
Text(
|
||||
text = "$powDifficulty bits • ${NostrProofOfWork.estimateMiningTime(powDifficulty)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
text = stringResource(
|
||||
R.string.about_difficulty_value,
|
||||
powDifficulty,
|
||||
NostrProofOfWork.estimateMiningTime(powDifficulty)
|
||||
),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
fontSize = 12.sp,
|
||||
color = palette.textSecondary
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Slider(
|
||||
value = powDifficulty.toFloat(),
|
||||
onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) },
|
||||
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 = palette.accentGreen,
|
||||
activeTrackColor = palette.accentGreen,
|
||||
inactiveTrackColor = palette.surfaceVariant
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
Text(
|
||||
text = when {
|
||||
powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none)
|
||||
@ -526,7 +502,7 @@ fun AboutSheet(
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f)
|
||||
color = palette.textTertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -539,13 +515,15 @@ fun AboutSheet(
|
||||
val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) }
|
||||
val torProvider = remember { ArtiTorManager.getInstance() }
|
||||
val torStatus by torProvider.statusFlow.collectAsState()
|
||||
|
||||
|
||||
if (torMode.value == TorMode.ON) {
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding),
|
||||
color = palette.surface,
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@ -556,16 +534,21 @@ fun AboutSheet(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
val statusColor = when {
|
||||
torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||
torStatus.running -> Color(0xFFFF9500)
|
||||
else -> Color(0xFFFF3B30)
|
||||
torStatus.running && torStatus.bootstrapPercent >= 100 -> palette.accentGreen
|
||||
torStatus.running -> palette.accentOrange
|
||||
else -> palette.accentRed
|
||||
}
|
||||
Surface(color = statusColor, shape = CircleShape, modifier = Modifier.size(10.dp)) {}
|
||||
Text(
|
||||
text = if (torStatus.running) "Connected (${torStatus.bootstrapPercent}%)" else "Disconnected",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
text = if (torStatus.running) {
|
||||
stringResource(R.string.about_tor_connected, torStatus.bootstrapPercent)
|
||||
} else {
|
||||
stringResource(R.string.about_tor_disconnected)
|
||||
},
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.onSurface
|
||||
color = palette.textPrimary
|
||||
)
|
||||
}
|
||||
if (torStatus.lastLogLine.isNotEmpty()) {
|
||||
@ -573,7 +556,7 @@ fun AboutSheet(
|
||||
text = torStatus.lastLogLine.take(120),
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
color = palette.textTertiary,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
@ -583,49 +566,22 @@ fun AboutSheet(
|
||||
}
|
||||
}
|
||||
|
||||
// Emergency Warning
|
||||
// Security audit warning
|
||||
item(key = "warning") {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 20.dp)
|
||||
.fillMaxWidth(),
|
||||
color = colorScheme.error.copy(alpha = 0.1f),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.error,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.about_emergency_title),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = colorScheme.error
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.about_emergency_tip),
|
||||
fontSize = 13.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AboutWarningCard(modifier = Modifier.padding(top = 24.dp))
|
||||
}
|
||||
} // end BasicInfo tab
|
||||
|
||||
// Footer
|
||||
item(key = "footer") {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp),
|
||||
.padding(
|
||||
start = AboutHorizontalPadding,
|
||||
end = AboutHorizontalPadding,
|
||||
top = 24.dp
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
@ -641,9 +597,9 @@ fun AboutSheet(
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.about_footer),
|
||||
fontSize = 12.sp,
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.4f)
|
||||
color = palette.textTertiary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
@ -656,7 +612,7 @@ fun AboutSheet(
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
|
||||
.background(palette.background.copy(alpha = topBarAlpha))
|
||||
) {
|
||||
CloseButton(
|
||||
onClick = onDismiss,
|
||||
|
||||
@ -2,20 +2,27 @@ package com.bitchat.android.ui
|
||||
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
@ -30,28 +37,71 @@ import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.core.ui.component.button.BitChatBrandButton
|
||||
import com.bitchat.android.ui.theme.BitchatMotion
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
|
||||
/**
|
||||
* Header components for ChatScreen
|
||||
* Extracted from ChatScreen.kt for better organization
|
||||
*/
|
||||
|
||||
/** Height of the chat top bar. Taller than the old 42.dp so 40.dp tap targets fit properly. */
|
||||
val ChatHeaderHeight = 48.dp
|
||||
|
||||
/** Standard icon size in the header. */
|
||||
private val HeaderIconSize = 18.dp
|
||||
|
||||
/** Minimum tap target for every interactive element in the header. */
|
||||
private val HeaderTapTarget = 40.dp
|
||||
|
||||
/** Corner radius for the header's tappable label+icon clusters. */
|
||||
private val HeaderClusterShape = RoundedCornerShape(8.dp)
|
||||
|
||||
/**
|
||||
* A minimum-48x40 tap target wrapping a small icon.
|
||||
*
|
||||
* The old header used bare 16.dp icons with `Modifier.clickable`, which produced tap targets far
|
||||
* below the accessibility minimum and made the channel/bookmark controls genuinely hard to hit.
|
||||
*/
|
||||
@Composable
|
||||
private fun HeaderIconButton(
|
||||
onClick: () -> Unit,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(HeaderTapTarget)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClickLabel = contentDescription) { onClick() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TorStatusDot(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val torProvider = remember { com.bitchat.android.net.ArtiTorManager.getInstance() }
|
||||
val torStatus by torProvider.statusFlow.collectAsState()
|
||||
|
||||
if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) {
|
||||
val dotColor = when {
|
||||
torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) // Orange - bootstrapping
|
||||
torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851) // Green - connected
|
||||
else -> Color.Red // Red - error/disconnected
|
||||
val targetColor = when {
|
||||
torStatus.running && torStatus.bootstrapPercent < 100 -> palette.accentOrange
|
||||
torStatus.running && torStatus.bootstrapPercent >= 100 -> palette.accentGreen
|
||||
else -> palette.accentRed
|
||||
}
|
||||
// Cross-fade rather than snap: Tor flips through bootstrapping states frequently and a
|
||||
// hard colour cut in the corner of the screen reads as a glitch.
|
||||
val dotColor by animateColorAsState(
|
||||
targetValue = targetColor,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "torDotColor"
|
||||
)
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
) {
|
||||
@ -125,7 +175,7 @@ fun NicknameEditor(
|
||||
Text(
|
||||
text = stringResource(R.string.at_symbol),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colorScheme.primary.copy(alpha = 0.8f)
|
||||
color = colorScheme.primary.copy(alpha = 0.7f)
|
||||
)
|
||||
|
||||
BasicTextField(
|
||||
@ -144,7 +194,7 @@ fun NicknameEditor(
|
||||
}
|
||||
),
|
||||
modifier = Modifier
|
||||
.widthIn(max = 120.dp)
|
||||
.widthIn(max = 140.dp)
|
||||
.horizontalScroll(scrollState)
|
||||
)
|
||||
}
|
||||
@ -161,45 +211,56 @@ fun PeerCounter(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
val palette = LocalBitchatPalette.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) palette.accentGreen else palette.textTertiary)
|
||||
}
|
||||
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) palette.accentBlue else palette.textTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Peers come and go constantly; fading the tint avoids a flicker every time the count
|
||||
// crosses zero.
|
||||
val animatedCountColor by animateColorAsState(
|
||||
targetValue = countColor,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "peerCountColor"
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier.clickable { onClick() }.padding(end = 8.dp) // Added right margin to match "bitchat" logo spacing
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier
|
||||
.clip(HeaderClusterShape)
|
||||
.clickable { onClick() }
|
||||
.height(HeaderTapTarget)
|
||||
.padding(horizontal = 6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Group,
|
||||
// `Groups` reads as a crowd at 18.dp, where `Group`'s two-person silhouette turns
|
||||
// into an indistinct blob.
|
||||
imageVector = Icons.Filled.Groups,
|
||||
contentDescription = when (selectedLocationChannel) {
|
||||
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),
|
||||
tint = countColor
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
tint = animatedCountColor
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
Text(
|
||||
text = "$peopleCount",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = countColor,
|
||||
fontSize = 16.sp,
|
||||
color = animatedCountColor,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
@ -207,8 +268,7 @@ fun PeerCounter(
|
||||
Text(
|
||||
text = stringResource(R.string.channel_count_prefix) + "${joinedChannels.size}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isConnected) Color(0xFF00C851) else Color.Red,
|
||||
fontSize = 16.sp,
|
||||
color = if (isConnected) palette.accentGreen else palette.accentRed,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
@ -266,48 +326,36 @@ private fun ChannelHeader(
|
||||
onSidebarClick: () -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
// Back button - positioned all the way to the left with minimal margin
|
||||
Button(
|
||||
// Back: a chevron alone is unambiguous here and buys back ~40.dp of title space that
|
||||
// the old "< back" label consumed.
|
||||
HeaderIconButton(
|
||||
onClick = onBackClick,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = colorScheme.primary
|
||||
),
|
||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), // Reduced horizontal padding
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.offset(x = (-8).dp) // Move even further left to minimize margin
|
||||
contentDescription = stringResource(R.string.back),
|
||||
modifier = Modifier.align(Alignment.CenterStart)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.chat_back),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Title - perfectly centered regardless of other elements
|
||||
Text(
|
||||
text = stringResource(R.string.chat_channel_prefix, channel),
|
||||
text = "#$channel",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color(0xFFFF9500), // Orange to match input field
|
||||
color = colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.clip(HeaderClusterShape)
|
||||
.clickable { onSidebarClick() }
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
|
||||
|
||||
// Leave button - positioned on the right
|
||||
TextButton(
|
||||
onClick = onLeaveChannel,
|
||||
@ -315,8 +363,8 @@ private fun ChannelHeader(
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.chat_leave),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Red
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = palette.accentRed
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -334,6 +382,7 @@ private fun MainHeader(
|
||||
viewModel: ChatViewModel
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val palette = LocalBitchatPalette.current
|
||||
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
|
||||
val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
|
||||
@ -349,80 +398,49 @@ private fun MainHeader(
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
BitChatBrandButton(
|
||||
onClick = onTitleClick,
|
||||
onTripleClick = onTripleTitleClick,
|
||||
contentDescription = stringResource(R.string.cd_open_about),
|
||||
)
|
||||
// MARK: - Identity cluster
|
||||
BitChatBrandButton(
|
||||
onClick = onTitleClick,
|
||||
onTripleClick = onTripleTitleClick,
|
||||
contentDescription = stringResource(R.string.cd_open_about),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "/",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = "/",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
// Dimmed: the slash is a separator, not content. At full brightness it competed
|
||||
// with the nickname beside it.
|
||||
color = colorScheme.primary.copy(alpha = 0.45f),
|
||||
modifier = Modifier.padding(horizontal = 2.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
|
||||
NicknameEditor(
|
||||
value = nickname,
|
||||
onValueChange = onNicknameChange
|
||||
)
|
||||
}
|
||||
|
||||
// Right section with location channels button and peer counter
|
||||
NicknameEditor(
|
||||
value = nickname,
|
||||
onValueChange = onNicknameChange
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// MARK: - Status cluster. Order matches the design: bookmark, channel, people.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp)
|
||||
// Tight, because every child below is its own >=40.dp tap target.
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
|
||||
// Unread private messages badge (click to open most recent DM)
|
||||
if (hasUnreadPrivateMessages.isNotEmpty()) {
|
||||
// Render icon directly to avoid symbol resolution issues
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
contentDescription = stringResource(R.string.cd_unread_private_messages),
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.clickable { viewModel.openLatestUnreadPrivateChat() },
|
||||
tint = Color(0xFFFF9500)
|
||||
)
|
||||
}
|
||||
|
||||
// Location channels button (matching iOS implementation) and bookmark grouped tightly
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 4.dp)) {
|
||||
LocationChannelsButton(
|
||||
viewModel = viewModel,
|
||||
onClick = onLocationChannelsClick
|
||||
)
|
||||
|
||||
// Bookmark toggle for current geohash (not shown for mesh)
|
||||
val currentGeohash: String? = when (val sc = selectedLocationChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> sc.channel.geohash
|
||||
else -> null
|
||||
}
|
||||
if (currentGeohash != null) {
|
||||
val isBookmarked = bookmarks.contains(currentGeohash)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 2.dp) // minimal gap between geohash and bookmark
|
||||
.size(20.dp)
|
||||
.clickable { bookmarksStore.toggle(currentGeohash) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
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),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
HeaderIconButton(
|
||||
onClick = { viewModel.openLatestUnreadPrivateChat() },
|
||||
contentDescription = stringResource(R.string.cd_unread_private_messages)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
contentDescription = stringResource(R.string.cd_unread_private_messages),
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
tint = palette.accentOrange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -432,19 +450,43 @@ private fun MainHeader(
|
||||
onClick = onLocationNotesClick
|
||||
)
|
||||
|
||||
// Tor status dot when Tor is enabled
|
||||
TorStatusDot(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.padding(start = 0.dp, end = 2.dp)
|
||||
)
|
||||
|
||||
// Tor status dot. Wrapped in a fixed box so its optical position no longer depends
|
||||
// on ad-hoc asymmetric padding.
|
||||
Box(modifier = Modifier.size(16.dp), contentAlignment = Alignment.Center) {
|
||||
TorStatusDot(modifier = Modifier.size(6.dp))
|
||||
}
|
||||
|
||||
// PoW status indicator
|
||||
PoWStatusIndicator(
|
||||
modifier = Modifier,
|
||||
style = PoWIndicatorStyle.COMPACT
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
|
||||
// Bookmark toggle for current geohash (not shown for mesh)
|
||||
val currentGeohash: String? = when (val sc = selectedLocationChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> sc.channel.geohash
|
||||
else -> null
|
||||
}
|
||||
if (currentGeohash != null) {
|
||||
val isBookmarked = bookmarks.contains(currentGeohash)
|
||||
HeaderIconButton(
|
||||
onClick = { bookmarksStore.toggle(currentGeohash) },
|
||||
contentDescription = stringResource(R.string.cd_toggle_bookmark)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
|
||||
contentDescription = stringResource(R.string.cd_toggle_bookmark),
|
||||
tint = if (isBookmarked) palette.accentGreen else palette.textSecondary,
|
||||
modifier = Modifier.size(HeaderIconSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LocationChannelsButton(
|
||||
viewModel = viewModel,
|
||||
onClick = onLocationChannelsClick
|
||||
)
|
||||
|
||||
PeerCounter(
|
||||
connectedPeers = connectedPeers.filter { it != viewModel.myPeerID },
|
||||
joinedChannels = joinedChannels,
|
||||
@ -458,56 +500,64 @@ private fun MainHeader(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Current channel indicator: a globe for geohash channels, a Bluetooth glyph for the local mesh.
|
||||
*
|
||||
* The design brief asked for the "addition of globe icon to represent channels". Previously this
|
||||
* was a text-only badge wrapped in an M3 [Button], which imposed a hidden 58.dp minimum width
|
||||
* and 40.dp minimum height that fought the header's explicit sizing.
|
||||
*/
|
||||
@Composable
|
||||
private fun LocationChannelsButton(
|
||||
viewModel: ChatViewModel,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
val palette = LocalBitchatPalette.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
|
||||
}
|
||||
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
|
||||
}
|
||||
null -> "#mesh" to Color(0xFF007AFF) // Default to mesh
|
||||
|
||||
val isLocation = selectedChannel is com.bitchat.android.geohash.ChannelID.Location
|
||||
val badgeText = when (val channel = selectedChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> "#${channel.channel.geohash}"
|
||||
else -> "#mesh"
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onClick,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = badgeColor
|
||||
),
|
||||
contentPadding = PaddingValues(start = 4.dp, end = 0.dp, top = 2.dp, bottom = 2.dp)
|
||||
val badgeColor = if (isLocation) palette.accentGreen else palette.accentBlue
|
||||
val badgeIcon = if (isLocation) Icons.Outlined.Public else Icons.Filled.Bluetooth
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier
|
||||
.clip(HeaderClusterShape)
|
||||
.clickable(onClickLabel = stringResource(R.string.location_channels_title)) { onClick() }
|
||||
.height(HeaderTapTarget)
|
||||
.padding(horizontal = 6.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = badgeText,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = badgeColor,
|
||||
maxLines = 1
|
||||
Icon(
|
||||
imageVector = badgeIcon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
tint = badgeColor
|
||||
)
|
||||
|
||||
Text(
|
||||
text = badgeText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = badgeColor,
|
||||
maxLines = 1
|
||||
)
|
||||
|
||||
// Teleportation indicator (like iOS)
|
||||
if (teleported) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PinDrop,
|
||||
contentDescription = stringResource(R.string.cd_teleported),
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = badgeColor
|
||||
)
|
||||
|
||||
// Teleportation indicator (like iOS)
|
||||
if (teleported) {
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.PinDrop,
|
||||
contentDescription = stringResource(R.string.cd_teleported),
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = badgeColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,8 @@ package com.bitchat.android.ui
|
||||
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
@ -26,6 +28,8 @@ import androidx.compose.ui.zIndex
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.ui.media.FullScreenImageViewer
|
||||
import com.bitchat.android.ui.theme.BitchatMotion
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
|
||||
/**
|
||||
* Main ChatScreen - REFACTORED to use component-based architecture
|
||||
@ -40,6 +44,7 @@ import com.bitchat.android.ui.media.FullScreenImageViewer
|
||||
@Composable
|
||||
fun ChatScreen(viewModel: ChatViewModel) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val palette = LocalBitchatPalette.current
|
||||
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
@ -114,7 +119,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
.fillMaxSize()
|
||||
.background(colorScheme.background) // Extend background to fill entire screen including status bar
|
||||
) {
|
||||
val headerHeight = 42.dp
|
||||
val headerHeight = ChatHeaderHeight
|
||||
|
||||
// Main content area that responds to keyboard/window insets
|
||||
Column(
|
||||
@ -263,14 +268,23 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.offset(y = headerHeight)
|
||||
.zIndex(1f),
|
||||
color = colorScheme.outline.copy(alpha = 0.3f)
|
||||
thickness = 1.dp,
|
||||
color = palette.outlineVariant
|
||||
)
|
||||
|
||||
// Scroll-to-bottom floating button
|
||||
AnimatedVisibility(
|
||||
visible = isScrolledUp,
|
||||
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
|
||||
// Short and eased: the button appears mid-scroll, so a slow entrance draws the eye
|
||||
// away from the messages the user is actually reading.
|
||||
enter = slideInVertically(
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
initialOffsetY = { it / 2 }
|
||||
) + fadeIn(tween(BitchatMotion.STANDARD_MS)),
|
||||
exit = slideOutVertically(
|
||||
animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing),
|
||||
targetOffsetY = { it / 2 }
|
||||
) + fadeOut(tween(BitchatMotion.QUICK_MS)),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 16.dp, bottom = 64.dp)
|
||||
@ -280,16 +294,16 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = colorScheme.background,
|
||||
color = palette.surface,
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 6.dp,
|
||||
border = BorderStroke(2.dp, Color(0xFF00C851))
|
||||
border = BorderStroke(1.dp, palette.accentGreen)
|
||||
) {
|
||||
IconButton(onClick = { forceScrollToBottom = !forceScrollToBottom }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowDownward,
|
||||
contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom),
|
||||
tint = Color(0xFF00C851)
|
||||
tint = palette.accentGreen
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -394,12 +408,14 @@ fun ChatInputSection(
|
||||
colorScheme: ColorScheme,
|
||||
showMediaButtons: Boolean
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = colorScheme.background
|
||||
) {
|
||||
// No divider above the composer: the pill's own border provides the separation, and a
|
||||
// full-width rule on top of it read as a double line.
|
||||
Column {
|
||||
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
|
||||
// Command suggestions box
|
||||
if (showCommandSuggestions && commandSuggestions.isNotEmpty()) {
|
||||
CommandSuggestionsBox(
|
||||
@ -407,7 +423,7 @@ fun ChatInputSection(
|
||||
onSuggestionClick = onCommandSuggestionClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f))
|
||||
HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant)
|
||||
}
|
||||
// Mention suggestions box
|
||||
if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) {
|
||||
@ -416,7 +432,7 @@ fun ChatInputSection(
|
||||
onSuggestionClick = onMentionSuggestionClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f))
|
||||
HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant)
|
||||
}
|
||||
MessageInput(
|
||||
value = messageText,
|
||||
@ -434,7 +450,6 @@ fun ChatInputSection(
|
||||
}
|
||||
}
|
||||
}
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ChatFloatingHeader(
|
||||
headerHeight: Dp,
|
||||
@ -459,35 +474,38 @@ private fun ChatFloatingHeader(
|
||||
.windowInsetsPadding(WindowInsets.statusBars), // Extend into status bar area
|
||||
color = colorScheme.background // Solid background color extending into status bar
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
ChatHeaderContent(
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
viewModel = viewModel,
|
||||
onBackClick = {
|
||||
when {
|
||||
selectedPrivatePeer != null -> viewModel.endPrivateChat()
|
||||
currentChannel != null -> viewModel.switchToChannel(null)
|
||||
}
|
||||
},
|
||||
onSidebarClick = onSidebarToggle,
|
||||
onTripleClick = onPanicClear,
|
||||
onShowAppInfo = onShowAppInfo,
|
||||
onLocationChannelsClick = onLocationChannelsClick,
|
||||
onLocationNotesClick = {
|
||||
// Ensure location is loaded before showing sheet
|
||||
locationManager.refreshChannels()
|
||||
onLocationNotesClick()
|
||||
// A plain Row rather than M3's TopAppBar. TopAppBar silently injects a 4.dp horizontal
|
||||
// pad plus a 12.dp title inset and applies its own minimum heights, which made the
|
||||
// header's spacing impossible to specify exactly.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(headerHeight)
|
||||
.padding(start = 12.dp, end = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
ChatHeaderContent(
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
viewModel = viewModel,
|
||||
onBackClick = {
|
||||
when {
|
||||
selectedPrivatePeer != null -> viewModel.endPrivateChat()
|
||||
currentChannel != null -> viewModel.switchToChannel(null)
|
||||
}
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent
|
||||
),
|
||||
modifier = Modifier.height(headerHeight) // Ensure compact header height
|
||||
)
|
||||
},
|
||||
onSidebarClick = onSidebarToggle,
|
||||
onTripleClick = onPanicClear,
|
||||
onShowAppInfo = onShowAppInfo,
|
||||
onLocationChannelsClick = onLocationChannelsClick,
|
||||
onLocationNotesClick = {
|
||||
// Ensure location is loaded before showing sheet
|
||||
locationManager.refreshChannels()
|
||||
onLocationNotesClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,8 +8,8 @@ import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import com.bitchat.android.ui.theme.BitchatPalette
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@ -18,6 +18,15 @@ import java.util.*
|
||||
* Extracted from ChatScreen.kt for better organization
|
||||
*/
|
||||
|
||||
/** Opacity applied to the `#abcd` disambiguation suffix so the readable name dominates. */
|
||||
internal const val SUFFIX_ALPHA = 0.55f
|
||||
|
||||
/** Background opacity for a mention chip referring to somebody else. */
|
||||
internal const val MENTION_CHIP_ALPHA = 0.18f
|
||||
|
||||
/** Background opacity for a mention chip referring to you. Slightly stronger to catch the eye. */
|
||||
internal const val MENTION_CHIP_ALPHA_SELF = 0.22f
|
||||
|
||||
/**
|
||||
* Get RSSI-based color for signal strength visualization
|
||||
*/
|
||||
@ -32,146 +41,21 @@ fun getRSSIColor(rssi: Int): Color {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format message as annotated string with iOS-style formatting
|
||||
* Timestamp at END, peer colors, hashtag suffix handling
|
||||
*/
|
||||
fun formatMessageAsAnnotatedString(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
// Determine if this message was sent by self
|
||||
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||
|
||||
if (message.sender != "system") {
|
||||
// Get base color for this peer (iOS-style color assignment)
|
||||
val baseColor = if (isSelf) {
|
||||
Color(0xFFFF9500) // Orange for self (iOS orange)
|
||||
} else {
|
||||
getPeerColor(message, isDark)
|
||||
}
|
||||
|
||||
// Split sender into base name and hashtag suffix
|
||||
val (baseName, suffix) = splitSuffix(message.sender)
|
||||
|
||||
// Sender prefix "<@"
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append("<@")
|
||||
builder.pop()
|
||||
|
||||
// Base name (clickable)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
val nicknameStart = builder.length
|
||||
val truncatedBase = truncateNickname(baseName)
|
||||
builder.append(truncatedBase)
|
||||
val nicknameEnd = builder.length
|
||||
|
||||
// Add click annotation for nickname (store canonical sender name with hash if available)
|
||||
if (!isSelf) {
|
||||
builder.addStringAnnotation(
|
||||
tag = "nickname_click",
|
||||
annotation = (message.originalSender ?: message.sender),
|
||||
start = nicknameStart,
|
||||
end = nicknameEnd
|
||||
)
|
||||
}
|
||||
builder.pop()
|
||||
|
||||
// Hashtag suffix in lighter color (iOS style)
|
||||
if (suffix.isNotEmpty()) {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor.copy(alpha = 0.6f),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append(suffix)
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
// Sender suffix "> "
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append("> ")
|
||||
builder.pop()
|
||||
|
||||
// Message content with iOS-style hashtag and mention highlighting
|
||||
appendIOSFormattedContent(
|
||||
builder,
|
||||
message.content,
|
||||
message.mentions,
|
||||
currentUserNickname,
|
||||
baseColor,
|
||||
isSelf,
|
||||
)
|
||||
|
||||
// iOS-style timestamp at the END (smaller, grey)
|
||||
// Timestamp (and optional PoW badge)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray.copy(alpha = 0.7f),
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
))
|
||||
builder.append(" [${timeFormatter.format(message.timestamp)}]")
|
||||
// If message has valid PoW difficulty, append bits immediately after timestamp with minimal spacing
|
||||
message.powDifficulty?.let { bits ->
|
||||
if (bits > 0) {
|
||||
builder.append(" ⛨${bits}b")
|
||||
}
|
||||
}
|
||||
builder.pop()
|
||||
|
||||
} else {
|
||||
// System message - iOS style
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray,
|
||||
fontSize = (BASE_FONT_SIZE - 2).sp,
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
||||
))
|
||||
builder.append("* ${message.content} *")
|
||||
builder.pop()
|
||||
|
||||
// Timestamp for system messages too
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray.copy(alpha = 0.5f),
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
))
|
||||
builder.append(" [${timeFormatter.format(message.timestamp)}]")
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the sender label used by the two-row text-message layout.
|
||||
* Build the sender label shown above the first message of a group.
|
||||
*
|
||||
* Renders `@name` plus a dimmed `#abcd` suffix. The name carries a `nickname_click`
|
||||
* annotation for everyone except yourself.
|
||||
*/
|
||||
fun formatTextMessageSender(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme
|
||||
palette: BitchatPalette
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
val isDark =
|
||||
colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||
val senderColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
|
||||
val senderWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
val senderColor = if (isSelf) palette.accentOrange else getPeerColor(message, palette.isDark)
|
||||
val senderWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
|
||||
val (baseName, suffix) = splitSuffix(message.sender)
|
||||
|
||||
builder.pushStyle(
|
||||
@ -198,7 +82,7 @@ fun formatTextMessageSender(
|
||||
if (suffix.isNotEmpty()) {
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = senderColor.copy(alpha = 0.6f),
|
||||
color = senderColor.copy(alpha = SUFFIX_ALPHA),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = senderWeight
|
||||
)
|
||||
@ -212,6 +96,9 @@ fun formatTextMessageSender(
|
||||
|
||||
/**
|
||||
* Build the compact timestamp and optional proof-of-work label.
|
||||
*
|
||||
* Used standalone by media rows; text messages get the same span appended inline to the end of
|
||||
* their body via [appendTrailingTimestamp].
|
||||
*/
|
||||
fun formatTextMessageMetadata(
|
||||
message: BitchatMessage,
|
||||
@ -233,66 +120,124 @@ fun formatTextMessageMetadata(
|
||||
}
|
||||
|
||||
/**
|
||||
* Build only the message body while retaining mention, URL and geohash styling.
|
||||
* Append the timestamp (and optional PoW difficulty) directly after the message body so it
|
||||
* trails the final words rather than occupying its own column.
|
||||
*
|
||||
* Deliberately carries no click annotation: the timestamp is decoration, and making it
|
||||
* tappable would create dead zones inside the message body.
|
||||
*/
|
||||
private fun appendTrailingTimestamp(
|
||||
builder: AnnotatedString.Builder,
|
||||
message: BitchatMessage,
|
||||
palette: BitchatPalette,
|
||||
timeFormatter: SimpleDateFormat
|
||||
) {
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = palette.textTertiary,
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp,
|
||||
fontWeight = FontWeight.Normal
|
||||
)
|
||||
)
|
||||
builder.append(" ")
|
||||
builder.append(timeFormatter.format(message.timestamp))
|
||||
message.powDifficulty?.takeIf { it > 0 }?.let { bits ->
|
||||
builder.append(" ⛨${bits}b")
|
||||
}
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the message body: neutral text with mention/URL/geohash accents, followed by an inline
|
||||
* trailing timestamp.
|
||||
*
|
||||
* Body text is intentionally neutral rather than peer-colored. Colour is reserved for
|
||||
* `@names`, which is what makes a busy channel scannable.
|
||||
*/
|
||||
fun formatTextMessageBody(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme
|
||||
palette: BitchatPalette,
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()),
|
||||
includeTimestamp: Boolean = true
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
val isDark =
|
||||
colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||
val accentColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
|
||||
|
||||
appendIOSFormattedContent(
|
||||
builder = builder,
|
||||
content = message.content,
|
||||
mentions = message.mentions,
|
||||
currentUserNickname = currentUserNickname,
|
||||
baseColor = accentColor,
|
||||
isSelf = isSelf,
|
||||
contentColor = colorScheme.onSurface
|
||||
palette = palette,
|
||||
)
|
||||
|
||||
if (includeTimestamp) {
|
||||
appendTrailingTimestamp(builder, message, palette, timeFormatter)
|
||||
}
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build only the nickname + timestamp header line for a message, matching styles of normal messages.
|
||||
* Build a system / background-action line, e.g. `// Tor started. Routing all chats… 11:09:56`.
|
||||
*
|
||||
* The `//` prefix reads as machine narration in a monospace context and is far quieter than
|
||||
* the previous `* italic asterisk *` treatment, which competed with real messages.
|
||||
*/
|
||||
fun formatSystemMessage(
|
||||
message: BitchatMessage,
|
||||
palette: BitchatPalette,
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = palette.textSecondary,
|
||||
fontSize = (BASE_FONT_SIZE - 2).sp,
|
||||
fontWeight = FontWeight.Normal
|
||||
)
|
||||
)
|
||||
builder.append("// ")
|
||||
builder.append(message.content)
|
||||
builder.pop()
|
||||
|
||||
appendTrailingTimestamp(builder, message, palette, timeFormatter)
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Header line for media (image / audio / file) rows.
|
||||
*
|
||||
* Matches the text-message treatment: `@name#abcd` with no angle brackets, followed by an
|
||||
* inline trailing timestamp. Media rows have no body text to trail, so the timestamp sits on
|
||||
* the same line as the name.
|
||||
*/
|
||||
fun formatMessageHeaderAnnotatedString(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
palette: BitchatPalette,
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()),
|
||||
includeSender: Boolean = true
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||
|
||||
if (message.sender != "system") {
|
||||
val baseColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
|
||||
if (message.sender == "system") {
|
||||
return formatSystemMessage(message, palette, timeFormatter)
|
||||
}
|
||||
|
||||
if (includeSender) {
|
||||
val baseColor = if (isSelf) palette.accentOrange else getPeerColor(message, palette.isDark)
|
||||
val weight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
|
||||
val (baseName, suffix) = splitSuffix(message.sender)
|
||||
|
||||
// "<@"
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append("<@")
|
||||
builder.pop()
|
||||
|
||||
// Base name (clickable when not self)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = weight
|
||||
)
|
||||
)
|
||||
builder.append("@")
|
||||
val nicknameStart = builder.length
|
||||
builder.append(truncateNickname(baseName))
|
||||
val nicknameEnd = builder.length
|
||||
@ -306,53 +251,20 @@ fun formatMessageHeaderAnnotatedString(
|
||||
}
|
||||
builder.pop()
|
||||
|
||||
// Hashtag suffix
|
||||
if (suffix.isNotEmpty()) {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor.copy(alpha = 0.6f),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = baseColor.copy(alpha = SUFFIX_ALPHA),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = weight
|
||||
)
|
||||
)
|
||||
builder.append(suffix)
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
// Sender suffix ">"
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append(">")
|
||||
builder.pop()
|
||||
|
||||
// Timestamp and optional PoW bits, matching normal message appearance
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray.copy(alpha = 0.7f),
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
))
|
||||
builder.append(" [${timeFormatter.format(message.timestamp)}]")
|
||||
message.powDifficulty?.let { bits ->
|
||||
if (bits > 0) builder.append(" ⛨${bits}b")
|
||||
}
|
||||
builder.pop()
|
||||
} else {
|
||||
// System message header (should rarely apply to voice)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray,
|
||||
fontSize = (BASE_FONT_SIZE - 2).sp,
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
||||
))
|
||||
builder.append("* ${message.content} *")
|
||||
builder.pop()
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray.copy(alpha = 0.5f),
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
))
|
||||
builder.append(" [${timeFormatter.format(message.timestamp)}]")
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
appendTrailingTimestamp(builder, message, palette, timeFormatter)
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
@ -385,7 +297,12 @@ fun getPeerColor(message: BitchatMessage, isDark: Boolean): Color {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate consistent peer color using djb2 hash (matches iOS algorithm exactly)
|
||||
* Generate consistent peer color using djb2 hash.
|
||||
*
|
||||
* The hash and hue derivation are byte-identical to the iOS implementation, so a given peer
|
||||
* resolves to the same hue on both platforms. Saturation and value are deliberately higher
|
||||
* than iOS: the redesigned chat surface renders message bodies in neutral near-white, so
|
||||
* nicknames need more chroma to stay distinguishable at a glance.
|
||||
*/
|
||||
fun colorForPeerSeed(seed: String, isDark: Boolean): Color {
|
||||
// djb2 hash algorithm (matches iOS implementation)
|
||||
@ -402,8 +319,8 @@ fun colorForPeerSeed(seed: String, isDark: Boolean): Color {
|
||||
hue = (hue + 0.12) % 1.0
|
||||
}
|
||||
|
||||
val saturation = if (isDark) 0.50 else 0.70
|
||||
val brightness = if (isDark) 0.85 else 0.35
|
||||
val saturation = if (isDark) 0.62 else 0.85
|
||||
val brightness = if (isDark) 0.92 else 0.45
|
||||
|
||||
return Color.hsv(
|
||||
hue = (hue * 360).toFloat(),
|
||||
@ -430,17 +347,27 @@ fun splitSuffix(name: String): Pair<String, String> {
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS-style content formatting with proper hashtag and mention handling
|
||||
* iOS-style content formatting with proper hashtag and mention handling.
|
||||
*
|
||||
* Redesign notes:
|
||||
* - Plain text renders in [BitchatPalette.textPrimary]; colour is reserved for `@mentions`,
|
||||
* links and geohashes.
|
||||
* - Mentions get a tinted background chip so they read as a distinct token inside a sentence.
|
||||
* The chip is tinted by *the mentioned peer's* colour, not the sender's, so `@alice` looks
|
||||
* the same everywhere she is referenced.
|
||||
* - Neither "self" nor "you were mentioned" bolds the whole body any more. Bolding entire
|
||||
* paragraphs was the single biggest source of visual noise in the old layout; the mention
|
||||
* chip carries that emphasis instead.
|
||||
*/
|
||||
private fun appendIOSFormattedContent(
|
||||
builder: AnnotatedString.Builder,
|
||||
content: String,
|
||||
mentions: List<String>?,
|
||||
currentUserNickname: String,
|
||||
baseColor: Color,
|
||||
isSelf: Boolean,
|
||||
contentColor: Color = baseColor,
|
||||
palette: BitchatPalette,
|
||||
) {
|
||||
val contentColor = palette.textPrimary
|
||||
val linkColor = palette.accentBlue
|
||||
|
||||
// iOS-style patterns: allow optional '#abcd' suffix in mentions
|
||||
val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex()
|
||||
val mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)".toRegex()
|
||||
@ -507,28 +434,28 @@ private fun appendIOSFormattedContent(
|
||||
}
|
||||
|
||||
allMatches.sortBy { it.first.first }
|
||||
|
||||
|
||||
val plainStyle = SpanStyle(
|
||||
color = contentColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = FontWeight.Normal
|
||||
)
|
||||
val linkStyle = SpanStyle(
|
||||
color = linkColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
|
||||
var lastEnd = 0
|
||||
val isMentioned = mentions?.contains(currentUserNickname) == true
|
||||
|
||||
|
||||
for ((range, type) in allMatches) {
|
||||
// Add text before match
|
||||
if (lastEnd < range.first) {
|
||||
val beforeText = content.substring(lastEnd, range.first)
|
||||
if (beforeText.isNotEmpty()) {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = contentColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
if (isMentioned) {
|
||||
// Make entire message bold if user is mentioned
|
||||
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||
builder.append(beforeText)
|
||||
builder.pop()
|
||||
} else {
|
||||
builder.append(beforeText)
|
||||
}
|
||||
builder.pushStyle(plainStyle)
|
||||
builder.append(beforeText)
|
||||
builder.pop()
|
||||
}
|
||||
}
|
||||
@ -540,65 +467,55 @@ private fun appendIOSFormattedContent(
|
||||
// iOS-style mention with hashtag suffix support
|
||||
val mentionWithoutAt = matchText.removePrefix("@")
|
||||
val (mBase, mSuffix) = splitSuffix(mentionWithoutAt)
|
||||
|
||||
// Check if this mention targets current user
|
||||
|
||||
// Mentions targeting you are the one thing worth shouting about.
|
||||
val isMentionToMe = mBase == currentUserNickname
|
||||
val mentionColor = if (isMentionToMe) Color(0xFFFF9500) else baseColor
|
||||
|
||||
// "@" symbol
|
||||
val mentionColor = if (isMentionToMe) {
|
||||
palette.accentOrange
|
||||
} else {
|
||||
// Tint by the *mentioned* peer so a given name looks identical everywhere.
|
||||
colorForPeerSeed(mentionWithoutAt.lowercase(), palette.isDark)
|
||||
}
|
||||
val chipAlpha = if (isMentionToMe) MENTION_CHIP_ALPHA_SELF else MENTION_CHIP_ALPHA
|
||||
val mentionWeight = if (isMentionToMe) FontWeight.Bold else FontWeight.SemiBold
|
||||
|
||||
// A single outer span carrying the background makes the chip render as one
|
||||
// continuous rectangle. Pushing the background per-token would leave hairline
|
||||
// seams between "@", the name and the "#abcd" suffix.
|
||||
builder.pushStyle(SpanStyle(background = mentionColor.copy(alpha = chipAlpha)))
|
||||
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = mentionColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
|
||||
fontWeight = mentionWeight
|
||||
))
|
||||
builder.append("@")
|
||||
builder.pop()
|
||||
|
||||
// Base name (truncate for rendering)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = mentionColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
|
||||
))
|
||||
builder.append(truncateNickname(mBase))
|
||||
builder.pop()
|
||||
|
||||
|
||||
// Hashtag suffix in lighter color
|
||||
if (mSuffix.isNotEmpty()) {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = mentionColor.copy(alpha = 0.6f),
|
||||
color = mentionColor.copy(alpha = SUFFIX_ALPHA),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
|
||||
fontWeight = mentionWeight
|
||||
))
|
||||
builder.append(mSuffix)
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
builder.pop() // background chip
|
||||
}
|
||||
"hashtag" -> {
|
||||
// Render general hashtags like normal content
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = contentColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
if (isMentioned) {
|
||||
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||
builder.append(matchText)
|
||||
builder.pop()
|
||||
} else {
|
||||
builder.append(matchText)
|
||||
}
|
||||
builder.pushStyle(plainStyle)
|
||||
builder.append(matchText)
|
||||
builder.pop()
|
||||
}
|
||||
else -> {
|
||||
if (type == "geohash") {
|
||||
// Style geohash in blue, underlined, and add click annotation
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color(0xFF007AFF),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold,
|
||||
textDecoration = TextDecoration.Underline
|
||||
))
|
||||
// Style geohash as a link and add click annotation
|
||||
builder.pushStyle(linkStyle)
|
||||
val start = builder.length
|
||||
builder.append(matchText)
|
||||
val end = builder.length
|
||||
@ -611,13 +528,8 @@ private fun appendIOSFormattedContent(
|
||||
)
|
||||
builder.pop()
|
||||
} else if (type == "url") {
|
||||
// Style URL in blue, underlined, and add click annotation with the raw text
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color(0xFF007AFF),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold,
|
||||
textDecoration = TextDecoration.Underline
|
||||
))
|
||||
// Style URL as a link and add click annotation with the raw text
|
||||
builder.pushStyle(linkStyle)
|
||||
val start = builder.length
|
||||
builder.append(matchText)
|
||||
val end = builder.length
|
||||
@ -630,11 +542,7 @@ private fun appendIOSFormattedContent(
|
||||
builder.pop()
|
||||
} else {
|
||||
// Fallback: treat as normal text
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = contentColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
builder.pushStyle(plainStyle)
|
||||
builder.append(matchText)
|
||||
builder.pop()
|
||||
}
|
||||
@ -647,18 +555,8 @@ private fun appendIOSFormattedContent(
|
||||
// Add remaining text
|
||||
if (lastEnd < content.length) {
|
||||
val remainingText = content.substring(lastEnd)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = contentColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
if (isMentioned) {
|
||||
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||
builder.append(remainingText)
|
||||
builder.pop()
|
||||
} else {
|
||||
builder.append(remainingText)
|
||||
}
|
||||
builder.pushStyle(plainStyle)
|
||||
builder.append(remainingText)
|
||||
builder.pop()
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package com.bitchat.android.ui
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.Explore
|
||||
@ -17,6 +18,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import java.util.*
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@ -52,41 +54,21 @@ fun GeohashPeopleList(
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||
|
||||
Column {
|
||||
// Header matching iOS style
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.LocationOn,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.geohash_people_header),
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
Column(modifier = modifier) {
|
||||
if (geohashPeople.isEmpty()) {
|
||||
SheetSectionLabel(text = stringResource(R.string.section_on_location))
|
||||
// Empty state - matches iOS "nobody around..."
|
||||
Text(
|
||||
text = stringResource(R.string.nobody_around),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = BASE_FONT_SIZE.sp
|
||||
),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = palette.textTertiary,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = SheetHorizontalPadding + 14.dp,
|
||||
vertical = 12.dp
|
||||
)
|
||||
)
|
||||
} else {
|
||||
// Get current geohash identity for "me" detection
|
||||
@ -129,12 +111,20 @@ fun GeohashPeopleList(
|
||||
counts
|
||||
}
|
||||
|
||||
val firstID = orderedPeople.firstOrNull()?.id
|
||||
|
||||
orderedPeople.forEach { person ->
|
||||
// Split by presence: people physically in the geohash versus people who teleported
|
||||
// in. Mixing them hid the fact that a "nearby" channel can contain remote users.
|
||||
fun personTeleported(person: GeoPerson): Boolean = if (person.id == myHex) {
|
||||
isTeleported
|
||||
} else {
|
||||
viewModel.isPersonTeleported(person.id)
|
||||
}
|
||||
|
||||
val (teleportedPeople, localPeople) = orderedPeople.partition { personTeleported(it) }
|
||||
|
||||
@Composable
|
||||
fun personRow(person: GeoPerson) {
|
||||
GeohashPersonItem(
|
||||
person = person,
|
||||
isFirst = person.id == firstID,
|
||||
isMe = myHex != null && person.id == myHex,
|
||||
hasUnreadDM = unreadPrivateMessages.contains("nostr_${person.id.take(16)}"),
|
||||
isTeleported = person.id != myHex && viewModel.isPersonTeleported(person.id),
|
||||
@ -153,6 +143,16 @@ fun GeohashPeopleList(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (localPeople.isNotEmpty()) {
|
||||
SheetSectionLabel(text = stringResource(R.string.section_on_location))
|
||||
localPeople.forEach { personRow(it) }
|
||||
}
|
||||
|
||||
if (teleportedPeople.isNotEmpty()) {
|
||||
SheetSectionLabel(text = stringResource(R.string.section_teleported_in))
|
||||
teleportedPeople.forEach { personRow(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -160,7 +160,6 @@ fun GeohashPeopleList(
|
||||
@Composable
|
||||
private fun GeohashPersonItem(
|
||||
person: GeoPerson,
|
||||
isFirst: Boolean,
|
||||
isMe: Boolean,
|
||||
hasUnreadDM: Boolean,
|
||||
isTeleported: Boolean,
|
||||
@ -171,12 +170,21 @@ private fun GeohashPersonItem(
|
||||
showHashSuffix: Boolean,
|
||||
onTap: () -> Unit
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
Surface(
|
||||
onClick = onTap,
|
||||
color = palette.surface,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = SheetHorizontalPadding, vertical = 3.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onTap() }
|
||||
.padding(horizontal = 24.dp, vertical = 4.dp)
|
||||
.padding(top = if (isFirst) 10.dp else 0.dp),
|
||||
.heightIn(min = 44.dp)
|
||||
.padding(horizontal = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Icon logic matching iOS exactly
|
||||
@ -185,16 +193,16 @@ private fun GeohashPersonItem(
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
contentDescription = stringResource(R.string.cd_unread_message),
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = Color(0xFFFF9500) // iOS orange
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = palette.accentOrange
|
||||
)
|
||||
} else {
|
||||
// Face icon with teleportation state
|
||||
val (iconName, iconColor) = when {
|
||||
isMe && isMyTeleported -> "face.dashed" to Color(0xFFFF9500) // Orange for teleported me
|
||||
isTeleported -> "face.dashed" to colorScheme.onSurface // Regular color for teleported others
|
||||
isMe -> "face.smiling" to Color(0xFFFF9500) // Orange for me
|
||||
else -> "face.smiling" to colorScheme.onSurface // Regular color for others
|
||||
isMe && isMyTeleported -> "face.dashed" to palette.accentOrange
|
||||
isTeleported -> "face.dashed" to palette.textSecondary
|
||||
isMe -> "face.smiling" to palette.accentOrange
|
||||
else -> "face.smiling" to palette.textSecondary
|
||||
}
|
||||
|
||||
// Use appropriate Material icon (closest match to iOS SF Symbols)
|
||||
@ -206,7 +214,7 @@ private fun GeohashPersonItem(
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User",
|
||||
modifier = Modifier.size(12.dp),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) // Make dashed faces slightly transparent
|
||||
)
|
||||
}
|
||||
@ -219,9 +227,8 @@ private fun GeohashPersonItem(
|
||||
val suffix = if (showHashSuffix) suffixRaw else ""
|
||||
|
||||
// Get consistent peer color (matches iOS color assignment exactly)
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val assignedColor = viewModel.colorForNostrPubkey(person.id, isDark)
|
||||
val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor
|
||||
val assignedColor = viewModel.colorForNostrPubkey(person.id, palette.isDark)
|
||||
val baseColor = if (isMe) palette.accentOrange else assignedColor
|
||||
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
@ -230,42 +237,36 @@ private fun GeohashPersonItem(
|
||||
// Base name with peer-specific color
|
||||
Text(
|
||||
text = baseName,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal
|
||||
),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium,
|
||||
color = baseColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
|
||||
// Suffix (collision-resistant #abcd) in lighter shade
|
||||
if (suffix.isNotEmpty()) {
|
||||
Text(
|
||||
text = suffix,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = BASE_FONT_SIZE.sp
|
||||
),
|
||||
color = baseColor.copy(alpha = 0.6f)
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium,
|
||||
color = baseColor.copy(alpha = SUFFIX_ALPHA)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// "You" indicator for current user
|
||||
if (isMe) {
|
||||
Text(
|
||||
text = stringResource(R.string.you_suffix),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = BASE_FONT_SIZE.sp
|
||||
),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
color = baseColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,9 @@ package com.bitchat.android.ui
|
||||
// [Goose] TODO: Replace inline file attachment stub with FilePickerButton abstraction that dispatches via FileShareDispatcher
|
||||
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@ -15,6 +18,7 @@ import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@ -38,6 +42,8 @@ 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.BitchatMotion
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import com.bitchat.android.features.voice.normalizeAmplitudeSample
|
||||
import com.bitchat.android.features.voice.AudioWaveformExtractor
|
||||
import com.bitchat.android.ui.media.RealtimeScrollingWaveform
|
||||
@ -53,7 +59,10 @@ import com.bitchat.android.ui.media.FilePickerButton
|
||||
* VisualTransformation that styles slash commands with background and color
|
||||
* while preserving cursor positioning and click handling
|
||||
*/
|
||||
class SlashCommandVisualTransformation : VisualTransformation {
|
||||
class SlashCommandVisualTransformation(
|
||||
private val commandColor: Color,
|
||||
private val commandBackground: Color,
|
||||
) : VisualTransformation {
|
||||
override fun filter(text: AnnotatedString): TransformedText {
|
||||
val slashCommandRegex = Regex("(/\\w+)(?=\\s|$)")
|
||||
val annotatedString = buildAnnotatedString {
|
||||
@ -68,10 +77,10 @@ class SlashCommandVisualTransformation : VisualTransformation {
|
||||
// Add the styled slash command
|
||||
withStyle(
|
||||
style = SpanStyle(
|
||||
color = Color(0xFF00FF7F), // Bright green
|
||||
color = commandColor,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
background = Color(0xFF2D2D2D) // Dark gray background
|
||||
background = commandBackground
|
||||
)
|
||||
) {
|
||||
append(match.value)
|
||||
@ -97,7 +106,10 @@ class SlashCommandVisualTransformation : VisualTransformation {
|
||||
* VisualTransformation that styles mentions with background and color
|
||||
* while preserving cursor positioning and click handling
|
||||
*/
|
||||
class MentionVisualTransformation : VisualTransformation {
|
||||
class MentionVisualTransformation(
|
||||
private val mentionColor: Color,
|
||||
private val mentionBackground: Color,
|
||||
) : VisualTransformation {
|
||||
override fun filter(text: AnnotatedString): TransformedText {
|
||||
val mentionRegex = Regex("@([a-zA-Z0-9_]+)")
|
||||
val annotatedString = buildAnnotatedString {
|
||||
@ -112,9 +124,12 @@ class MentionVisualTransformation : VisualTransformation {
|
||||
// Add the styled mention
|
||||
withStyle(
|
||||
style = SpanStyle(
|
||||
color = Color(0xFFFF9500), // Orange
|
||||
color = mentionColor,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
// Mirrors the mention chip used in rendered messages, so what you type
|
||||
// looks like what everyone will see.
|
||||
background = mentionBackground
|
||||
)
|
||||
) {
|
||||
append(match.value)
|
||||
@ -159,6 +174,24 @@ class CombinedVisualTransformation(private val transformations: List<VisualTrans
|
||||
|
||||
|
||||
|
||||
/** Minimum height of the composer pill: a single line of 15.sp text plus 12.dp of padding. */
|
||||
private val ComposerMinHeight = 44.dp
|
||||
|
||||
/**
|
||||
* Composer corner radius.
|
||||
*
|
||||
* Fixed rather than "50%": at [ComposerMinHeight] this yields a true capsule, and when the field
|
||||
* grows to multiple lines it stays a generously rounded rectangle instead of degenerating into
|
||||
* the stadium shape a percentage radius would produce.
|
||||
*/
|
||||
private val ComposerShape = RoundedCornerShape(22.dp)
|
||||
|
||||
/** Diameter of the circular send affordance nested inside the pill. */
|
||||
private val SendButtonSize = 32.dp
|
||||
|
||||
/** Tap target for the media affordances sitting outside the pill. */
|
||||
private val MediaButtonSize = 40.dp
|
||||
|
||||
@Composable
|
||||
fun MessageInput(
|
||||
value: TextFieldValue,
|
||||
@ -174,6 +207,7 @@ fun MessageInput(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val palette = LocalBitchatPalette.current
|
||||
val isFocused = remember { mutableStateOf(false) }
|
||||
val hasText = value.text.isNotBlank() // Check if there's text for send button state
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
@ -182,82 +216,121 @@ fun MessageInput(
|
||||
var elapsedMs by remember { mutableStateOf(0L) }
|
||||
var amplitude by remember { mutableStateOf(0) }
|
||||
|
||||
Row(
|
||||
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), // Reduced padding
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Text input with placeholder OR visualizer when recording
|
||||
Box(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
// 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
|
||||
),
|
||||
cursorBrush = SolidColor(if (isRecording) Color.Transparent else colorScheme.primary),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||
keyboardActions = KeyboardActions(onSend = {
|
||||
if (hasText) onSend() // Only send if there's text
|
||||
}),
|
||||
visualTransformation = CombinedVisualTransformation(
|
||||
listOf(SlashCommandVisualTransformation(), MentionVisualTransformation())
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { focusState ->
|
||||
isFocused.value = focusState.isFocused
|
||||
}
|
||||
)
|
||||
// Recording turns the pill's outline red; a cross-fade keeps that from flashing.
|
||||
val borderColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
isRecording -> palette.accentRed.copy(alpha = 0.6f)
|
||||
isFocused.value -> palette.outline
|
||||
else -> palette.outlineVariant
|
||||
},
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "composerBorder"
|
||||
)
|
||||
|
||||
// Show placeholder when there's no text and not recording
|
||||
if (value.text.isEmpty() && !isRecording) {
|
||||
Text(
|
||||
text = stringResource(R.string.type_a_message_placeholder),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
Row(
|
||||
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
// Bottom-aligned so that when the field grows to several lines the send button and the
|
||||
// media buttons stay pinned next to the last line, as in the design's long-text state.
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
// MARK: - The pill. Holds the field and the send button as one visual object.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.heightIn(min = ComposerMinHeight)
|
||||
.background(palette.surfaceVariant, ComposerShape)
|
||||
.border(1.dp, borderColor, ComposerShape),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 12.dp)
|
||||
) {
|
||||
// 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 = colorScheme.onSurface.copy(alpha = 0.5f), // Muted grey
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
cursorBrush = SolidColor(if (isRecording) Color.Transparent else colorScheme.primary),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||
keyboardActions = KeyboardActions(onSend = {
|
||||
if (hasText) onSend() // Only send if there's text
|
||||
}),
|
||||
// Cap the growth so a pasted wall of text cannot swallow the message list.
|
||||
maxLines = 6,
|
||||
visualTransformation = remember(palette) {
|
||||
CombinedVisualTransformation(
|
||||
listOf(
|
||||
SlashCommandVisualTransformation(
|
||||
commandColor = palette.accentGreen,
|
||||
commandBackground = palette.accentGreen.copy(alpha = 0.14f),
|
||||
),
|
||||
MentionVisualTransformation(
|
||||
mentionColor = palette.accentOrange,
|
||||
mentionBackground = palette.accentOrange.copy(alpha = MENTION_CHIP_ALPHA),
|
||||
),
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { focusState ->
|
||||
isFocused.value = focusState.isFocused
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Overlay the real-time scrolling waveform while recording
|
||||
if (isRecording) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
RealtimeScrollingWaveform(
|
||||
modifier = Modifier.weight(1f).height(32.dp),
|
||||
amplitudeNorm = normalizeAmplitudeSample(amplitude)
|
||||
)
|
||||
Spacer(Modifier.width(20.dp))
|
||||
val secs = (elapsedMs / 1000).toInt()
|
||||
val mm = secs / 60
|
||||
val ss = secs % 60
|
||||
val maxSecs = 10 // 10 second max recording time
|
||||
val maxMm = maxSecs / 60
|
||||
val maxSs = maxSecs % 60
|
||||
// Show placeholder when there's no text and not recording
|
||||
if (value.text.isEmpty() && !isRecording) {
|
||||
Text(
|
||||
text = String.format("%02d:%02d / %02d:%02d", mm, ss, maxMm, maxSs),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.primary,
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
text = stringResource(R.string.type_a_message_placeholder),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = palette.textTertiary,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp)) // Reduced spacing
|
||||
|
||||
// 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)
|
||||
|
||||
// Overlay the real-time scrolling waveform while recording
|
||||
if (isRecording) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
RealtimeScrollingWaveform(
|
||||
modifier = Modifier.weight(1f).height(24.dp),
|
||||
amplitudeNorm = normalizeAmplitudeSample(amplitude)
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
val secs = (elapsedMs / 1000).toInt()
|
||||
val mm = secs / 60
|
||||
val ss = secs % 60
|
||||
val maxSecs = 10 // 10 second max recording time
|
||||
val maxMm = maxSecs / 60
|
||||
val maxSs = maxSecs % 60
|
||||
Text(
|
||||
text = String.format("%02d:%02d / %02d:%02d", mm, ss, maxMm, maxSs),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = palette.accentRed,
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SendButton(
|
||||
hasText = hasText,
|
||||
isAccented = selectedPrivatePeer != null || currentChannel != null,
|
||||
onSend = onSend,
|
||||
modifier = Modifier.padding(end = 6.dp, bottom = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Media affordances, outside the pill, only while the field is empty.
|
||||
if (value.text.isEmpty() && showMediaButtons) {
|
||||
// Ensure latest values are used when finishing recording
|
||||
val latestSelectedPeer = rememberUpdatedState(selectedPrivatePeer)
|
||||
val latestChannel = rememberUpdatedState(currentChannel)
|
||||
@ -265,26 +338,15 @@ fun MessageInput(
|
||||
|
||||
// Image button (image picker) - hide during recording
|
||||
if (!isRecording) {
|
||||
// Revert to original separate buttons: round File button (left) and the old Image plus button (right)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
// DISABLE FILE PICKER
|
||||
//FilePickerButton(
|
||||
// onFileReady = { path ->
|
||||
// onSendFileNote(latestSelectedPeer.value, latestChannel.value, path)
|
||||
// }
|
||||
//)
|
||||
ImagePickerButton(
|
||||
onImageReady = { outPath ->
|
||||
onSendImageNote(latestSelectedPeer.value, latestChannel.value, outPath)
|
||||
}
|
||||
)
|
||||
}
|
||||
ImagePickerButton(
|
||||
onImageReady = { outPath ->
|
||||
onSendImageNote(latestSelectedPeer.value, latestChannel.value, outPath)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(1.dp))
|
||||
|
||||
VoiceRecordButton(
|
||||
backgroundColor = bg,
|
||||
backgroundColor = colorScheme.primary,
|
||||
onStart = {
|
||||
isRecording = true
|
||||
elapsedMs = 0L
|
||||
@ -313,72 +375,81 @@ fun MessageInput(
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
} else {
|
||||
// Send button with enabled/disabled state
|
||||
IconButton(
|
||||
onClick = { if (hasText) onSend() }, // Only execute if there's text
|
||||
enabled = hasText, // Enable only when there's text
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
// Update send button to match input field colors
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.background(
|
||||
color = if (!hasText) {
|
||||
// Disabled state - muted grey
|
||||
colorScheme.onSurface.copy(alpha = 0.3f)
|
||||
} else if (selectedPrivatePeer != null || currentChannel != null) {
|
||||
// Orange for both private messages and channels when enabled
|
||||
Color(0xFFFF9500).copy(alpha = 0.75f)
|
||||
} else if (colorScheme.background == Color.Black) {
|
||||
Color(0xFF00FF00).copy(alpha = 0.75f) // Bright green for dark theme
|
||||
} else {
|
||||
Color(0xFF008000).copy(alpha = 0.75f) // Dark green for light theme
|
||||
},
|
||||
shape = CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowUpward,
|
||||
contentDescription = stringResource(id = R.string.send_message),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (!hasText) {
|
||||
// Disabled state - muted grey icon
|
||||
colorScheme.onSurface.copy(alpha = 0.5f)
|
||||
} else if (selectedPrivatePeer != null || currentChannel != null) {
|
||||
// Black arrow on orange for both private and channel modes
|
||||
Color.Black
|
||||
} else if (colorScheme.background == Color.Black) {
|
||||
Color.Black // Black arrow on bright green in dark theme
|
||||
} else {
|
||||
Color.White // White arrow on dark green in light theme
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-stop handled inside VoiceRecordButton
|
||||
}
|
||||
|
||||
/**
|
||||
* Circular send affordance nested in the bottom-right of the composer pill.
|
||||
*
|
||||
* Goes from a flat grey disc to a solid accent the moment there is something to send, which is
|
||||
* the clearest possible signal that the return key will do something.
|
||||
*/
|
||||
@Composable
|
||||
private fun SendButton(
|
||||
hasText: Boolean,
|
||||
isAccented: Boolean,
|
||||
onSend: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
val targetBackground = when {
|
||||
!hasText -> palette.outline
|
||||
isAccented -> palette.accentOrange
|
||||
else -> colorScheme.primary
|
||||
}
|
||||
val targetTint = when {
|
||||
!hasText -> palette.textTertiary
|
||||
// Both accents are bright enough that black is the only legible arrow colour.
|
||||
else -> Color.Black
|
||||
}
|
||||
|
||||
val background by animateColorAsState(
|
||||
targetValue = targetBackground,
|
||||
animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing),
|
||||
label = "sendButtonBackground"
|
||||
)
|
||||
val tint by animateColorAsState(
|
||||
targetValue = targetTint,
|
||||
animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing),
|
||||
label = "sendButtonTint"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(SendButtonSize)
|
||||
.background(background, CircleShape)
|
||||
.clip(CircleShape)
|
||||
.clickable(enabled = hasText) { onSend() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowUpward,
|
||||
contentDescription = stringResource(id = R.string.send_message),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = tint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CommandSuggestionsBox(
|
||||
suggestions: List<CommandSuggestion>,
|
||||
onSuggestionClick: (CommandSuggestion) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.background(colorScheme.surface)
|
||||
.border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp))
|
||||
.padding(vertical = 8.dp)
|
||||
.background(palette.surface)
|
||||
.border(1.dp, palette.outlineVariant, RoundedCornerShape(8.dp))
|
||||
.padding(vertical = 6.dp)
|
||||
) {
|
||||
suggestions.forEach { suggestion: CommandSuggestion ->
|
||||
CommandSuggestionItem(
|
||||
@ -395,13 +466,14 @@ fun CommandSuggestionItem(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 12.dp, vertical = 3.dp)
|
||||
.background(Color.Gray.copy(alpha = 0.1f)),
|
||||
// Roomier rows: at 3.dp vertical these were below a comfortable tap height.
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
@ -419,7 +491,7 @@ fun CommandSuggestionItem(
|
||||
fontWeight = FontWeight.Medium
|
||||
),
|
||||
color = colorScheme.primary,
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
fontSize = (BASE_FONT_SIZE - 2).sp
|
||||
)
|
||||
|
||||
// Show syntax if any
|
||||
@ -429,8 +501,8 @@ fun CommandSuggestionItem(
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.8f),
|
||||
fontSize = (BASE_FONT_SIZE - 5).sp
|
||||
color = palette.textSecondary,
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
)
|
||||
}
|
||||
|
||||
@ -440,8 +512,8 @@ fun CommandSuggestionItem(
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
fontSize = (BASE_FONT_SIZE - 5).sp,
|
||||
color = palette.textTertiary,
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
@ -454,13 +526,13 @@ fun MentionSuggestionsBox(
|
||||
onSuggestionClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.background(colorScheme.surface)
|
||||
.border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp))
|
||||
.padding(vertical = 8.dp)
|
||||
.background(palette.surface)
|
||||
.border(1.dp, palette.outlineVariant, RoundedCornerShape(8.dp))
|
||||
.padding(vertical = 6.dp)
|
||||
) {
|
||||
suggestions.forEach { suggestion: String ->
|
||||
MentionSuggestionItem(
|
||||
@ -476,14 +548,13 @@ fun MentionSuggestionItem(
|
||||
suggestion: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 12.dp, vertical = 3.dp)
|
||||
.background(Color.Gray.copy(alpha = 0.1f)),
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
@ -492,19 +563,19 @@ fun MentionSuggestionItem(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
color = Color(0xFFFF9500), // Orange like mentions
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
color = palette.accentOrange,
|
||||
fontSize = (BASE_FONT_SIZE - 2).sp
|
||||
)
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.mention),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
fontSize = (BASE_FONT_SIZE - 5).sp
|
||||
color = palette.textTertiary,
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,11 +3,17 @@ package com.bitchat.android.ui
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bookmark
|
||||
@ -15,6 +21,7 @@ import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Map
|
||||
import androidx.compose.material.icons.filled.PinDrop
|
||||
import androidx.compose.material.icons.outlined.BookmarkBorder
|
||||
import androidx.compose.material.icons.outlined.Public
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@ -41,6 +48,11 @@ import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
|
||||
import com.bitchat.android.net.ArtiTorManager
|
||||
import com.bitchat.android.net.TorMode
|
||||
import com.bitchat.android.net.TorPreferenceManager
|
||||
import com.bitchat.android.ui.theme.BitchatMotion
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
|
||||
/**
|
||||
* Location Channels Sheet for selecting geohash-based location channels
|
||||
@ -109,11 +121,10 @@ fun LocationChannelsSheet(
|
||||
}
|
||||
}
|
||||
|
||||
// iOS system colors (matches iOS exactly)
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green
|
||||
val standardBlue = Color(0xFF007AFF) // iOS blue
|
||||
val palette = LocalBitchatPalette.current
|
||||
val standardGreen = palette.accentGreen
|
||||
val standardBlue = palette.accentBlue
|
||||
|
||||
if (isPresented) {
|
||||
BitchatBottomSheet(
|
||||
@ -129,14 +140,28 @@ fun LocationChannelsSheet(
|
||||
) {
|
||||
// Header Section
|
||||
item(key = "header") {
|
||||
Text(
|
||||
text = stringResource(R.string.location_channels_desc),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = SheetHorizontalPadding + 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
SheetHeaderBadge(icon = Icons.Outlined.Public)
|
||||
Text(
|
||||
text = stringResource(R.string.location_channels_heading),
|
||||
fontSize = 20.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.location_channels_desc),
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 17.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = palette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Permission controls if services enabled
|
||||
@ -156,7 +181,7 @@ fun LocationChannelsSheet(
|
||||
text = stringResource(R.string.location_permission_denied),
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = Color.Red.copy(alpha = 0.8f)
|
||||
color = palette.accentRed
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
@ -208,6 +233,11 @@ fun LocationChannelsSheet(
|
||||
// iOS: let nearby = manager.availableChannels.filter { $0.level != .building }
|
||||
if (availableChannels.isNotEmpty() && locationServicesEnabled) {
|
||||
val nearbyChannels = availableChannels.filter { it.level != GeohashChannelLevel.BUILDING }
|
||||
if (nearbyChannels.isNotEmpty()) {
|
||||
item(key = "nearby_header") {
|
||||
SheetSectionLabel(text = stringResource(R.string.section_nearby))
|
||||
}
|
||||
}
|
||||
items(nearbyChannels) { channel ->
|
||||
val coverage = coverageString(channel.geohash.length)
|
||||
val nameBase = locationNames[channel.level]
|
||||
@ -224,13 +254,14 @@ fun LocationChannelsSheet(
|
||||
titleColor = standardGreen,
|
||||
titleBold = highlight,
|
||||
trailingContent = {
|
||||
IconButton(onClick = { bookmarksStore.toggle(channel.geohash) }) {
|
||||
Icon(
|
||||
imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
|
||||
contentDescription = if (isBookmarked) stringResource(R.string.cd_remove_bookmark) else stringResource(R.string.cd_add_bookmark),
|
||||
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { bookmarksStore.toggle(channel.geohash) }) {
|
||||
Icon(
|
||||
imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
|
||||
contentDescription = if (isBookmarked) stringResource(R.string.cd_remove_bookmark) else stringResource(R.string.cd_add_bookmark),
|
||||
tint = if (isBookmarked) palette.accentGreen else palette.textSecondary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
// Selecting a suggested nearby channel is not a teleport
|
||||
@ -252,12 +283,13 @@ fun LocationChannelsSheet(
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
color = palette.textSecondary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.finding_nearby_channels),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = palette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -266,16 +298,7 @@ fun LocationChannelsSheet(
|
||||
// Bookmarked geohashes
|
||||
if (bookmarks.isNotEmpty()) {
|
||||
item(key = "bookmarked_header") {
|
||||
Text(
|
||||
text = stringResource(R.string.bookmarked),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 8.dp, bottom = 4.dp)
|
||||
)
|
||||
SheetSectionLabel(text = stringResource(R.string.section_bookmarked))
|
||||
}
|
||||
items(bookmarks) { gh ->
|
||||
val level = levelForLength(gh.length)
|
||||
@ -298,7 +321,8 @@ fun LocationChannelsSheet(
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Bookmark,
|
||||
contentDescription = stringResource(R.string.cd_remove_bookmark),
|
||||
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
|
||||
tint = palette.accentGreen,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
@ -321,16 +345,17 @@ fun LocationChannelsSheet(
|
||||
// Custom geohash teleport (iOS-style inline form)
|
||||
item(key = "custom_geohash") {
|
||||
Surface(
|
||||
color = Color.Transparent,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = palette.surface,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 2.dp)
|
||||
.padding(horizontal = SheetHorizontalPadding, vertical = 3.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
.heightIn(min = 44.dp)
|
||||
.padding(start = 14.dp, end = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@ -338,7 +363,7 @@ fun LocationChannelsSheet(
|
||||
text = stringResource(R.string.hash_symbol),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
color = palette.textTertiary
|
||||
)
|
||||
|
||||
BasicTextField(
|
||||
@ -358,8 +383,9 @@ fun LocationChannelsSheet(
|
||||
textStyle = androidx.compose.ui.text.TextStyle(
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
color = colorScheme.primary
|
||||
),
|
||||
cursorBrush = androidx.compose.ui.graphics.SolidColor(colorScheme.primary),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.onFocusChanged { focusState ->
|
||||
@ -381,7 +407,7 @@ fun LocationChannelsSheet(
|
||||
text = stringResource(R.string.geohash_placeholder),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
|
||||
color = palette.textTertiary
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
@ -405,14 +431,17 @@ fun LocationChannelsSheet(
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Map,
|
||||
contentDescription = stringResource(R.string.cd_open_map),
|
||||
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
tint = palette.textSecondary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
|
||||
val isValid = validateGeohash(normalized)
|
||||
|
||||
// iOS-style teleport button
|
||||
Button(
|
||||
// Compact teleport pill. Dims rather than disappearing when the
|
||||
// geohash is incomplete so its position stays predictable.
|
||||
val teleportColor = if (isValid) colorScheme.primary else palette.textTertiary
|
||||
Surface(
|
||||
onClick = {
|
||||
if (isValid) {
|
||||
val level = levelForLength(normalized.length)
|
||||
@ -426,25 +455,29 @@ fun LocationChannelsSheet(
|
||||
}
|
||||
},
|
||||
enabled = isValid,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = palette.surfaceVariant
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(32.dp)
|
||||
.padding(horizontal = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.teleport),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PinDrop,
|
||||
contentDescription = stringResource(R.string.cd_teleport),
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = teleportColor
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.teleport).uppercase(),
|
||||
fontSize = 11.sp,
|
||||
letterSpacing = 0.8.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = teleportColor
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -459,51 +492,103 @@ fun LocationChannelsSheet(
|
||||
text = customError!!,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = Color.Red,
|
||||
color = palette.accentRed,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(horizontal = SheetHorizontalPadding + 14.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Tor routing. Mirrors the toggle in About: this is where users actually
|
||||
// think about IP exposure, because location channels are the feature that
|
||||
// leaks it.
|
||||
item(key = "tor_routing") {
|
||||
val torProvider = remember { ArtiTorManager.getInstance() }
|
||||
val torAvailable = remember { torProvider.isTorAvailable() }
|
||||
var torMode by remember { mutableStateOf(TorPreferenceManager.get(context)) }
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = SheetHorizontalPadding, vertical = 3.dp)
|
||||
.padding(top = 16.dp),
|
||||
color = palette.surface,
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 14.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.location_tor_routing_title),
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (torAvailable) palette.textPrimary else palette.textTertiary
|
||||
)
|
||||
BitchatBadge(text = stringResource(R.string.badge_recommended))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.location_tor_routing_desc),
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = palette.textSecondary
|
||||
)
|
||||
}
|
||||
|
||||
Switch(
|
||||
checked = torMode == TorMode.ON,
|
||||
onCheckedChange = { enabled ->
|
||||
if (torAvailable) {
|
||||
torMode = if (enabled) TorMode.ON else TorMode.OFF
|
||||
TorPreferenceManager.set(context, torMode)
|
||||
}
|
||||
},
|
||||
enabled = torAvailable,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = Color.White,
|
||||
checkedTrackColor = palette.accentGreen,
|
||||
uncheckedThumbColor = Color.White,
|
||||
uncheckedTrackColor = palette.surfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Location services toggle button
|
||||
item(key = "location_toggle") {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 8.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (locationServicesEnabled) {
|
||||
locationManager.disableLocationServices()
|
||||
} else {
|
||||
locationManager.enableLocationServices()
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (locationServicesEnabled) {
|
||||
Color.Red.copy(alpha = 0.08f)
|
||||
} else {
|
||||
standardGreen.copy(alpha = 0.12f)
|
||||
},
|
||||
contentColor = if (locationServicesEnabled) {
|
||||
Color(0xFFBF1A1A)
|
||||
} else {
|
||||
standardGreen
|
||||
}
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = if (locationServicesEnabled) stringResource(R.string.disable_location_services) else stringResource(R.string.enable_location_services),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
SheetDestructiveButton(
|
||||
text = if (locationServicesEnabled) {
|
||||
stringResource(R.string.remove_location_access)
|
||||
} else {
|
||||
stringResource(R.string.enable_location_services)
|
||||
},
|
||||
isDestructive = locationServicesEnabled,
|
||||
onClick = {
|
||||
if (locationServicesEnabled) {
|
||||
locationManager.disableLocationServices()
|
||||
} else {
|
||||
locationManager.enableLocationServices()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(
|
||||
start = SheetHorizontalPadding,
|
||||
end = SheetHorizontalPadding,
|
||||
top = 16.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -561,26 +646,41 @@ private fun ChannelRow(
|
||||
trailingContent: (@Composable (() -> Unit))? = null,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
// iOS-style list row (plain button, no card background)
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
// Selected rows step up to surfaceVariant rather than showing a trailing checkmark: with a
|
||||
// leading status dot the fill alone communicates state and keeps the row's right edge free
|
||||
// for the bookmark control.
|
||||
val containerColor by animateColorAsState(
|
||||
targetValue = if (isSelected) palette.surfaceVariant else palette.surface,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "channelRowContainer"
|
||||
)
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
color = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = containerColor,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 2.dp)
|
||||
.padding(horizontal = SheetHorizontalPadding, vertical = 3.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (isSelected) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(end = 10.dp)
|
||||
.size(6.dp)
|
||||
.background(palette.accentGreen, CircleShape)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
@ -588,13 +688,16 @@ private fun ChannelRow(
|
||||
// Split title to handle count part with smaller font (iOS style)
|
||||
val (baseTitle, countSuffix) = splitTitleAndCount(title)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = baseTitle,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = if (titleBold) FontWeight.Bold else FontWeight.Normal,
|
||||
color = titleColor ?: MaterialTheme.colorScheme.onSurface
|
||||
fontWeight = if (titleBold) FontWeight.SemiBold else FontWeight.Medium,
|
||||
color = titleColor ?: palette.textPrimary
|
||||
)
|
||||
|
||||
countSuffix?.let { count ->
|
||||
@ -602,32 +705,21 @@ private fun ChannelRow(
|
||||
text = count,
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
color = palette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontSize = 12.sp,
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
color = palette.textSecondary
|
||||
)
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = stringResource(R.string.cd_selected),
|
||||
tint = Color(0xFF32D74B), // iOS green for checkmark
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (trailingContent != null) {
|
||||
trailingContent()
|
||||
}
|
||||
if (trailingContent != null) {
|
||||
trailingContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ import com.bitchat.android.R
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.nostr.LocationNotesManager
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
|
||||
/**
|
||||
* Location Notes button component for MainHeader
|
||||
@ -54,13 +55,13 @@ fun LocationNotesButton(
|
||||
val hasNotes = notesCount > 0
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier.size(24.dp)
|
||||
modifier = modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Description, // "long.text.page.and.pencil" equivalent
|
||||
contentDescription = stringResource(R.string.cd_location_notes),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (hasNotes) colorScheme.primary else Color.Gray
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = if (hasNotes) colorScheme.primary else LocalBitchatPalette.current.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,6 +81,7 @@ fun MessageWithMatrixAnimation(
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
showSender: Boolean = true,
|
||||
) {
|
||||
AnimatedMessageDisplay(
|
||||
message = message,
|
||||
@ -91,6 +92,7 @@ fun MessageWithMatrixAnimation(
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
modifier = modifier,
|
||||
showSender = showSender,
|
||||
)
|
||||
}
|
||||
|
||||
@ -107,6 +109,7 @@ private fun AnimatedMessageDisplay(
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
showSender: Boolean = true,
|
||||
) {
|
||||
var animatedContent by remember(message.id, message.content) {
|
||||
mutableStateOf(message.content)
|
||||
@ -174,6 +177,7 @@ private fun AnimatedMessageDisplay(
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
modifier = modifier,
|
||||
showSender = showSender,
|
||||
bodyContent = animatedContent,
|
||||
)
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
@ -36,6 +37,7 @@ import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import com.bitchat.android.nostr.GeohashAliasRegistry
|
||||
import com.bitchat.android.nostr.GeohashConversationRegistry
|
||||
import com.bitchat.android.services.ContactDirectory
|
||||
@ -68,6 +70,8 @@ fun MeshPeerListSheet(
|
||||
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
||||
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
val geohashPeopleCount = geohashPeople.size
|
||||
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
|
||||
val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() }
|
||||
|
||||
@ -100,19 +104,33 @@ fun MeshPeerListSheet(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp)
|
||||
) {
|
||||
// Header: badge + live participant count
|
||||
item(key = "people_header") {
|
||||
val peopleCount = when (selectedLocationChannel) {
|
||||
is ChannelID.Location -> geohashPeopleCount
|
||||
else -> connectedPeers.count { it != viewModel.myPeerID }
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = SheetHorizontalPadding + 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
SheetHeaderBadge(icon = Icons.Filled.Groups)
|
||||
Text(
|
||||
text = stringResource(R.string.people_count_title, peopleCount),
|
||||
fontSize = 20.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Channels section
|
||||
if (joinedChannels.isNotEmpty()) {
|
||||
item(key = "channels_header") {
|
||||
Text(
|
||||
text = stringResource(id = R.string.channels).uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 8.dp, bottom = 4.dp)
|
||||
)
|
||||
SheetSectionLabel(text = stringResource(R.string.section_channels))
|
||||
}
|
||||
|
||||
items(
|
||||
@ -222,22 +240,21 @@ private fun ChannelRow(
|
||||
onChannelClick: () -> Unit,
|
||||
onLeaveChannel: () -> Unit,
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
Surface(
|
||||
onClick = onChannelClick,
|
||||
color = if (isSelected) {
|
||||
colorScheme.primaryContainer.copy(alpha = 0.15f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = if (isSelected) palette.surfaceVariant else palette.surface,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 2.dp)
|
||||
.padding(horizontal = SheetHorizontalPadding, vertical = 3.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
.heightIn(min = 44.dp)
|
||||
.padding(start = 14.dp, end = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@ -253,15 +270,13 @@ private fun ChannelRow(
|
||||
colorScheme = colorScheme
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Text(
|
||||
text = channel,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = BASE_FONT_SIZE.sp
|
||||
),
|
||||
color = if (isSelected) colorScheme.primary else colorScheme.onSurface,
|
||||
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
color = if (isSelected) colorScheme.primary else palette.textPrimary,
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
@ -298,29 +313,20 @@ fun PeopleSection(
|
||||
SecureIdentityStateManager(context.applicationContext)
|
||||
}
|
||||
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.people).uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 8.dp, bottom = 4.dp)
|
||||
)
|
||||
SheetSectionLabel(text = stringResource(R.string.section_mesh))
|
||||
|
||||
if (connectedPeers.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.no_one_connected),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp
|
||||
),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = palette.textTertiary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 40.dp, vertical = 12.dp)
|
||||
.padding(horizontal = SheetHorizontalPadding + 14.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@ -547,26 +553,23 @@ private fun PeerItem(
|
||||
val isMe = displayName == "You" || peerID == currentNickname
|
||||
|
||||
// Get consistent peer color (iOS-compatible)
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val assignedColor = viewModel.colorForMeshPeer(peerID, isDark)
|
||||
val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor
|
||||
val palette = LocalBitchatPalette.current
|
||||
val assignedColor = viewModel.colorForMeshPeer(peerID, palette.isDark)
|
||||
val baseColor = if (isMe) palette.accentOrange else assignedColor
|
||||
|
||||
Surface(
|
||||
onClick = onItemClick,
|
||||
color = if (isSelected) {
|
||||
colorScheme.primaryContainer.copy(alpha = 0.15f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = if (isSelected) palette.surfaceVariant else palette.surface,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 2.dp)
|
||||
.padding(horizontal = SheetHorizontalPadding, vertical = 3.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
.heightIn(min = 44.dp)
|
||||
.padding(start = 14.dp, end = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@ -622,11 +625,9 @@ private fun PeerItem(
|
||||
// Base name with peer-specific color
|
||||
Text(
|
||||
text = baseName,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal
|
||||
),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium,
|
||||
color = baseColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@ -636,11 +637,10 @@ private fun PeerItem(
|
||||
if (suffix.isNotEmpty()) {
|
||||
Text(
|
||||
text = suffix,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = BASE_FONT_SIZE.sp
|
||||
),
|
||||
color = baseColor.copy(alpha = 0.6f)
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium,
|
||||
color = baseColor.copy(alpha = SUFFIX_ALPHA)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@ -41,6 +41,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.R
|
||||
@ -50,6 +51,10 @@ import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.ui.media.FileMessageItem
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import com.bitchat.android.ui.theme.MessageBodyTextStyle
|
||||
import com.bitchat.android.ui.theme.MessageSenderTextStyle
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
@ -116,25 +121,42 @@ fun MessagesList(
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
// Wider side gutters than the old 12.dp: the redesign trades a little line length for
|
||||
// a much calmer edge, and long monospace lines were running into the screen bezel.
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp),
|
||||
// Spacing is owned by each item so that a continuation of the same author can sit
|
||||
// tighter than the start of a new speaker's run.
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier = modifier,
|
||||
reverseLayout = true
|
||||
) {
|
||||
items(
|
||||
items = messages.asReversed(),
|
||||
key = { it.id }
|
||||
) { message ->
|
||||
MessageItem(
|
||||
message = message,
|
||||
messages = messages,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
val reversed = messages.asReversed()
|
||||
itemsIndexed(
|
||||
items = reversed,
|
||||
key = { _, message -> message.id }
|
||||
) { reversedIndex, message ->
|
||||
// reverseLayout renders index 0 at the bottom, so the chronological predecessor of
|
||||
// this row lives at a *higher* original index offset. Resolve against the original
|
||||
// list rather than the reversed view to keep the grouping logic readable.
|
||||
val originalIndex = messages.lastIndex - reversedIndex
|
||||
val previous = messages.getOrNull(originalIndex - 1)
|
||||
val isGrouped = MessageGrouping.shouldGroup(previous, message)
|
||||
|
||||
MessageItem(
|
||||
message = message,
|
||||
messages = messages,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
showSender = !isGrouped,
|
||||
topSpacing = MessageGrouping.topSpacingFor(
|
||||
isGrouped = isGrouped,
|
||||
isFirstInList = originalIndex == 0
|
||||
),
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -146,6 +168,8 @@ fun MessageItem(
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
messages: List<BitchatMessage> = emptyList(),
|
||||
showSender: Boolean = true,
|
||||
topSpacing: Dp = 0.dp,
|
||||
onNicknameClick: ((String) -> Unit)? = null,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
|
||||
onCancelTransfer: ((BitchatMessage) -> Unit)? = null,
|
||||
@ -155,7 +179,9 @@ fun MessageItem(
|
||||
val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = topSpacing),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
@ -174,6 +200,7 @@ fun MessageItem(
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
showSender = showSender,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
@ -211,12 +238,15 @@ fun MessageItem(
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
showSender: Boolean,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
onCancelTransfer: ((BitchatMessage) -> Unit)?,
|
||||
onImageClick: ((String, List<String>, Int) -> Unit)?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
// Image special rendering
|
||||
if (message.type == BitchatMessageType.Image) {
|
||||
com.bitchat.android.ui.media.ImageMessageItem(
|
||||
@ -226,6 +256,7 @@ fun MessageItem(
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
showSender = showSender,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
@ -243,6 +274,7 @@ fun MessageItem(
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
showSender = showSender,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
@ -269,8 +301,9 @@ fun MessageItem(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter
|
||||
palette = palette,
|
||||
timeFormatter = timeFormatter,
|
||||
includeSender = showSender
|
||||
)
|
||||
val haptic = LocalHapticFeedback.current
|
||||
AnnotatedClickableText(
|
||||
@ -287,7 +320,7 @@ fun MessageItem(
|
||||
},
|
||||
onLongPress = { onMessageLongPress?.invoke(message) },
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface,
|
||||
color = palette.textPrimary,
|
||||
)
|
||||
|
||||
// Try to load the file packet from the path
|
||||
@ -363,19 +396,20 @@ fun MessageItem(
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
showSender = showSender,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
modifier = modifier
|
||||
)
|
||||
} else if (message.sender == "system") {
|
||||
// Keep system messages on the compact legacy line.
|
||||
val annotatedText = formatMessageAsAnnotatedString(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter
|
||||
)
|
||||
// Background narration: `// Tor started. Routing all chats…`
|
||||
val annotatedText = remember(message, palette) {
|
||||
formatSystemMessage(
|
||||
message = message,
|
||||
palette = palette,
|
||||
timeFormatter = timeFormatter
|
||||
)
|
||||
}
|
||||
|
||||
val haptic = LocalHapticFeedback.current
|
||||
Text(
|
||||
@ -391,8 +425,10 @@ fun MessageItem(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = true,
|
||||
overflow = TextOverflow.Visible,
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
color = colorScheme.onSurface
|
||||
style = MessageBodyTextStyle.copy(
|
||||
fontSize = (BASE_FONT_SIZE - 2).sp,
|
||||
lineHeight = (BASE_FONT_SIZE + 3).sp,
|
||||
color = palette.textSecondary
|
||||
)
|
||||
)
|
||||
} else {
|
||||
@ -402,6 +438,7 @@ fun MessageItem(
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
showSender = showSender,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
modifier = modifier,
|
||||
@ -419,32 +456,30 @@ internal fun TextMessageLayout(
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
showSender: Boolean = true,
|
||||
bodyContent: String = message.content,
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val myPeerId = meshService.myPeerID
|
||||
val displayMessage = remember(message, bodyContent) {
|
||||
if (bodyContent == message.content) message else message.copy(content = bodyContent)
|
||||
}
|
||||
val senderText = remember(message, currentUserNickname, myPeerId, colorScheme) {
|
||||
val senderText = remember(message, currentUserNickname, myPeerId, palette) {
|
||||
formatTextMessageSender(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
palette = palette,
|
||||
)
|
||||
}
|
||||
val metadataText = remember(message.timestamp, message.powDifficulty, timeFormatter) {
|
||||
formatTextMessageMetadata(
|
||||
message = message,
|
||||
timeFormatter = timeFormatter,
|
||||
)
|
||||
}
|
||||
val bodyText = remember(displayMessage, currentUserNickname, myPeerId, colorScheme) {
|
||||
// The timestamp trails the body rather than occupying its own column, so a short message
|
||||
// no longer reserves a full-width row for eight grey characters.
|
||||
val bodyText = remember(displayMessage, currentUserNickname, palette, timeFormatter) {
|
||||
formatTextMessageBody(
|
||||
message = displayMessage,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
palette = palette,
|
||||
timeFormatter = timeFormatter,
|
||||
)
|
||||
}
|
||||
val isSelf = message.isFromSelf(currentUserNickname, myPeerId)
|
||||
@ -457,12 +492,9 @@ internal fun TextMessageLayout(
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(MessageGrouping.SENDER_TO_BODY_SPACING),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (showSender) {
|
||||
AnnotatedClickableText(
|
||||
text = senderText,
|
||||
annotationTags = listOf("nickname_click"),
|
||||
@ -476,18 +508,11 @@ internal fun TextMessageLayout(
|
||||
}
|
||||
},
|
||||
onLongPress = handleLongPress,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = false,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
AnnotatedClickableText(
|
||||
text = metadataText,
|
||||
annotationTags = emptyList(),
|
||||
onAnnotationClick = { _, _ -> false },
|
||||
onLongPress = handleLongPress,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = false,
|
||||
style = MessageSenderTextStyle,
|
||||
)
|
||||
}
|
||||
|
||||
@ -515,7 +540,7 @@ internal fun TextMessageLayout(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = true,
|
||||
overflow = TextOverflow.Visible,
|
||||
style = androidx.compose.ui.text.TextStyle(color = colorScheme.onSurface),
|
||||
style = MessageBodyTextStyle.copy(color = palette.textPrimary),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
79
app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt
Normal file
79
app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt
Normal file
@ -0,0 +1,79 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
|
||||
/**
|
||||
* Consecutive-message grouping for the chat surface.
|
||||
*
|
||||
* The redesign suppresses the `@sender` label on runs of messages from the same author so the
|
||||
* eye only has to register a name when the speaker actually changes. Grouped messages sit
|
||||
* closer together; a new group gets extra breathing room above it.
|
||||
*/
|
||||
object MessageGrouping {
|
||||
|
||||
/** Space above the first message of a group (i.e. one that renders its sender label). */
|
||||
val NEW_GROUP_SPACING: Dp = 10.dp
|
||||
|
||||
/** Space above a continuation message inside an existing group. */
|
||||
val GROUPED_SPACING: Dp = 4.dp
|
||||
|
||||
/** Gap between the sender label and the first line of the body. */
|
||||
val SENDER_TO_BODY_SPACING: Dp = 2.dp
|
||||
|
||||
/**
|
||||
* Messages further apart than this always start a new group, even from the same sender:
|
||||
* a reply an hour later is a new thought, not a continuation.
|
||||
*/
|
||||
const val GROUPING_WINDOW_MS: Long = 5 * 60 * 1000L
|
||||
|
||||
private const val SYSTEM_SENDER = "system"
|
||||
|
||||
/**
|
||||
* Whether [current] should be rendered as a continuation of [previous], hiding its sender
|
||||
* label.
|
||||
*
|
||||
* [previous] is the message immediately *before* [current] in chronological order. Note the
|
||||
* message list renders with `reverseLayout = true`, so callers must be careful to resolve
|
||||
* the chronological predecessor rather than the visually preceding item.
|
||||
*/
|
||||
fun shouldGroup(previous: BitchatMessage?, current: BitchatMessage): Boolean {
|
||||
if (previous == null) return false
|
||||
|
||||
// System/action lines never participate in grouping in either direction: they are
|
||||
// narration, and folding a real message into them would attribute it to "system".
|
||||
if (previous.sender == SYSTEM_SENDER || current.sender == SYSTEM_SENDER) return false
|
||||
|
||||
// Never group across the public/private boundary or between different channels.
|
||||
if (previous.isPrivate != current.isPrivate) return false
|
||||
if (previous.channel != current.channel) return false
|
||||
|
||||
if (!isSameSender(previous, current)) return false
|
||||
|
||||
val elapsed = current.timestamp.time - previous.timestamp.time
|
||||
return elapsed in 0..GROUPING_WINDOW_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity comparison. Peer IDs are authoritative when both messages carry one, because two
|
||||
* different peers can share a nickname. Falls back to the display name (which includes the
|
||||
* `#abcd` suffix) when peer IDs are unavailable, e.g. for locally injected messages.
|
||||
*/
|
||||
private fun isSameSender(previous: BitchatMessage, current: BitchatMessage): Boolean {
|
||||
val previousPeerID = previous.senderPeerID
|
||||
val currentPeerID = current.senderPeerID
|
||||
return if (previousPeerID != null && currentPeerID != null) {
|
||||
previousPeerID.equals(currentPeerID, ignoreCase = true)
|
||||
} else {
|
||||
previous.sender == current.sender
|
||||
}
|
||||
}
|
||||
|
||||
/** Top padding for a message given whether it continues the previous author's run. */
|
||||
fun topSpacingFor(isGrouped: Boolean, isFirstInList: Boolean): Dp = when {
|
||||
isFirstInList -> 0.dp
|
||||
isGrouped -> GROUPED_SPACING
|
||||
else -> NEW_GROUP_SPACING
|
||||
}
|
||||
}
|
||||
@ -56,7 +56,7 @@ fun VoiceRecordButton(
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(32.dp)
|
||||
.size(36.dp)
|
||||
.background(backgroundColor, CircleShape)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
|
||||
@ -21,6 +21,7 @@ import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
@Composable
|
||||
@ -33,8 +34,10 @@ fun AudioMessageItem(
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
onCancelTransfer: ((BitchatMessage) -> Unit)?,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
showSender: Boolean = true
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val path = message.content.trim()
|
||||
// Derive sending progress if applicable
|
||||
val (overrideProgress, overrideColor) = when (val st = message.deliveryStatus) {
|
||||
@ -51,8 +54,9 @@ fun AudioMessageItem(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter
|
||||
palette = palette,
|
||||
timeFormatter = timeFormatter,
|
||||
includeSender = showSender
|
||||
)
|
||||
val haptic = LocalHapticFeedback.current
|
||||
AnnotatedClickableText(
|
||||
@ -69,7 +73,7 @@ fun AudioMessageItem(
|
||||
},
|
||||
onLongPress = { onMessageLongPress?.invoke(message) },
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface,
|
||||
color = palette.textPrimary,
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
||||
@ -27,6 +27,7 @@ import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
@Composable
|
||||
@ -41,16 +42,19 @@ fun ImageMessageItem(
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
onCancelTransfer: ((BitchatMessage) -> Unit)?,
|
||||
onImageClick: ((String, List<String>, Int) -> Unit)?,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
showSender: Boolean = true
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val path = message.content.trim()
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter
|
||||
palette = palette,
|
||||
timeFormatter = timeFormatter,
|
||||
includeSender = showSender
|
||||
)
|
||||
val haptic = LocalHapticFeedback.current
|
||||
AnnotatedClickableText(
|
||||
@ -67,7 +71,7 @@ fun ImageMessageItem(
|
||||
},
|
||||
onLongPress = { onMessageLongPress?.invoke(message) },
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface,
|
||||
color = palette.textPrimary,
|
||||
)
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
@ -9,6 +9,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Camera
|
||||
import androidx.compose.material.icons.filled.Photo
|
||||
@ -17,6 +18,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@ -24,6 +26,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import com.bitchat.android.features.media.ImageUtils
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import java.io.File
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@ -88,7 +91,9 @@ fun ImagePickerButton(
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(32.dp)
|
||||
// 40.dp to match the header's tap targets and the composer's send button.
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.combinedClickable(
|
||||
onClick = { imagePicker.launch("image/*") },
|
||||
onLongClick = {
|
||||
@ -104,7 +109,7 @@ fun ImagePickerButton(
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PhotoCamera,
|
||||
contentDescription = stringResource(com.bitchat.android.R.string.pick_image),
|
||||
tint = Color.Gray,
|
||||
tint = LocalBitchatPalette.current.textSecondary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
106
app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt
Normal file
106
app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt
Normal file
@ -0,0 +1,106 @@
|
||||
package com.bitchat.android.ui.theme
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Semantic design tokens for the bitchat redesign.
|
||||
*
|
||||
* Material3's [androidx.compose.material3.ColorScheme] cannot express what this app needs:
|
||||
* `onSurface` is the terminal green used for chrome, while message bodies must render in a
|
||||
* neutral near-white. Rather than repurposing M3 slots (which would repaint ~40 unrelated
|
||||
* files unpredictably), the redesign exposes an explicit palette through
|
||||
* [LocalBitchatPalette].
|
||||
*
|
||||
* Consumers should prefer these tokens over ad-hoc hex literals so accents stay consistent
|
||||
* and light/dark pairs are defined in exactly one place.
|
||||
*/
|
||||
@Immutable
|
||||
data class BitchatPalette(
|
||||
/** Authoritative dark-mode flag. Prefer this over summing background channels. */
|
||||
val isDark: Boolean,
|
||||
|
||||
// MARK: - Surfaces
|
||||
/** Screen, header and composer background. Slightly green-tinted near-black. */
|
||||
val background: Color,
|
||||
/** Sheets, cards and list rows: one step above [background]. */
|
||||
val surface: Color,
|
||||
/** Composer pill fill, chips, selected rows: one step above [surface]. */
|
||||
val surfaceVariant: Color,
|
||||
/** Borders, drawn at full opacity (no `.copy(alpha = ...)` needed). */
|
||||
val outline: Color,
|
||||
/** Hairline dividers. */
|
||||
val outlineVariant: Color,
|
||||
|
||||
// MARK: - Text
|
||||
/** Message bodies and row titles. Neutral, not green. */
|
||||
val textPrimary: Color,
|
||||
/** Subtitles, secondary metadata, system/action messages. */
|
||||
val textSecondary: Color,
|
||||
/** Timestamps, placeholders, section labels, disabled states. */
|
||||
val textTertiary: Color,
|
||||
|
||||
// MARK: - Accents
|
||||
/** Geohash channels, bookmark-on, connected/OK states. */
|
||||
val accentGreen: Color,
|
||||
/** Mesh channel, links, geohash references. */
|
||||
val accentBlue: Color,
|
||||
/** Self, mentions targeting you, unread DMs. */
|
||||
val accentOrange: Color,
|
||||
/** Errors, warning card, destructive actions. */
|
||||
val accentRed: Color,
|
||||
/** Nostr reachability. */
|
||||
val accentPurple: Color,
|
||||
)
|
||||
|
||||
val DarkBitchatPalette = BitchatPalette(
|
||||
isDark = true,
|
||||
background = Color(0xFF070D07),
|
||||
surface = Color(0xFF0E150E),
|
||||
surfaceVariant = Color(0xFF182118),
|
||||
outline = Color(0xFF2A3A2A),
|
||||
outlineVariant = Color(0xFF1C271C),
|
||||
textPrimary = Color(0xFFE8EDE8),
|
||||
textSecondary = Color(0xFF9AA69A),
|
||||
textTertiary = Color(0xFF6B776B),
|
||||
accentGreen = Color(0xFF32D74B),
|
||||
accentBlue = Color(0xFF0A84FF),
|
||||
accentOrange = Color(0xFFFF9F0A),
|
||||
accentRed = Color(0xFFFF453A),
|
||||
accentPurple = Color(0xFFBF5AF2),
|
||||
)
|
||||
|
||||
val LightBitchatPalette = BitchatPalette(
|
||||
isDark = false,
|
||||
background = Color(0xFFFBFDFB),
|
||||
surface = Color(0xFFF2F6F2),
|
||||
surfaceVariant = Color(0xFFE7EDE7),
|
||||
outline = Color(0xFFCBD6CB),
|
||||
outlineVariant = Color(0xFFDEE6DE),
|
||||
textPrimary = Color(0xFF131A13),
|
||||
textSecondary = Color(0xFF4C574C),
|
||||
textTertiary = Color(0xFF757F75),
|
||||
accentGreen = Color(0xFF248A3D),
|
||||
accentBlue = Color(0xFF007AFF),
|
||||
accentOrange = Color(0xFFFF9500),
|
||||
accentRed = Color(0xFFD70015),
|
||||
accentPurple = Color(0xFFAF52DE),
|
||||
)
|
||||
|
||||
val LocalBitchatPalette = staticCompositionLocalOf { DarkBitchatPalette }
|
||||
|
||||
/**
|
||||
* Motion tokens. The redesign leans on short, snappy transitions: long durations read as
|
||||
* sluggish on a chat surface where the user is scanning quickly.
|
||||
*/
|
||||
object BitchatMotion {
|
||||
/** Icon tints, text colors, small fills. */
|
||||
const val QUICK_MS = 120
|
||||
|
||||
/** Tab indicators, pill growth, chip reveals. */
|
||||
const val STANDARD_MS = 180
|
||||
|
||||
/** Sheet-level fades and scroll-driven top bars. */
|
||||
const val EMPHASIZED_MS = 240
|
||||
}
|
||||
@ -9,6 +9,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
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
|
||||
@ -16,17 +17,23 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
|
||||
// Colors that match the iOS bitchat theme
|
||||
// Colors that match the iOS bitchat theme.
|
||||
// Surface/outline/error slots are derived from BitchatPalette so that screens which have not
|
||||
// yet migrated to the explicit palette still pick up the redesigned tinted surfaces.
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Color(0xFF39FF14), // Bright green (terminal-like)
|
||||
onPrimary = Color.Black,
|
||||
secondary = Color(0xFF2ECB10), // Darker green
|
||||
onSecondary = Color.Black,
|
||||
background = Color.Black,
|
||||
onBackground = Color(0xFF39FF14), // Green on black
|
||||
surface = Color(0xFF111111), // Very dark gray
|
||||
background = DarkBitchatPalette.background,
|
||||
onBackground = Color(0xFF39FF14), // Green on near-black
|
||||
surface = DarkBitchatPalette.surface,
|
||||
onSurface = Color(0xFF39FF14), // Green text
|
||||
error = Color(0xFFFF5555), // Red for errors
|
||||
surfaceVariant = DarkBitchatPalette.surfaceVariant,
|
||||
onSurfaceVariant = DarkBitchatPalette.textSecondary,
|
||||
outline = DarkBitchatPalette.outline,
|
||||
outlineVariant = DarkBitchatPalette.outlineVariant,
|
||||
error = DarkBitchatPalette.accentRed,
|
||||
onError = Color.Black
|
||||
)
|
||||
|
||||
@ -35,11 +42,15 @@ private val LightColorScheme = lightColorScheme(
|
||||
onPrimary = Color.White,
|
||||
secondary = Color(0xFF006600), // Even darker green
|
||||
onSecondary = Color.White,
|
||||
background = Color.White,
|
||||
onBackground = Color(0xFF008000), // Dark green on white
|
||||
surface = Color(0xFFF8F8F8), // Very light gray
|
||||
background = LightBitchatPalette.background,
|
||||
onBackground = Color(0xFF008000), // Dark green on off-white
|
||||
surface = LightBitchatPalette.surface,
|
||||
onSurface = Color(0xFF008000), // Dark green text
|
||||
error = Color(0xFFCC0000), // Dark red for errors
|
||||
surfaceVariant = LightBitchatPalette.surfaceVariant,
|
||||
onSurfaceVariant = LightBitchatPalette.textSecondary,
|
||||
outline = LightBitchatPalette.outline,
|
||||
outlineVariant = LightBitchatPalette.outlineVariant,
|
||||
error = LightBitchatPalette.accentRed,
|
||||
onError = Color.White
|
||||
)
|
||||
|
||||
@ -61,6 +72,7 @@ fun BitchatTheme(
|
||||
}
|
||||
|
||||
val colorScheme = if (shouldUseDark) DarkColorScheme else LightColorScheme
|
||||
val palette = if (shouldUseDark) DarkBitchatPalette else LightBitchatPalette
|
||||
|
||||
val view = LocalView.current
|
||||
SideEffect {
|
||||
@ -83,9 +95,11 @@ fun BitchatTheme(
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
CompositionLocalProvider(LocalBitchatPalette provides palette) {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,26 @@ import androidx.compose.ui.unit.sp
|
||||
// Base font size for consistent scaling across the app
|
||||
internal const val BASE_FONT_SIZE = com.bitchat.android.util.AppConstants.UI.BASE_FONT_SIZE_SP // sp - increased from 14sp for better readability
|
||||
|
||||
/**
|
||||
* Message body style. The generous leading (1.4x) is what gives the redesigned chat surface
|
||||
* its readable rhythm; without an explicit lineHeight, Compose falls back to the font's own
|
||||
* metrics and lines sit too tightly for long paragraphs.
|
||||
*/
|
||||
val MessageBodyTextStyle = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
lineHeight = (BASE_FONT_SIZE + 6).sp
|
||||
)
|
||||
|
||||
/** Sender label above a message group. Single line, never wraps. */
|
||||
val MessageSenderTextStyle = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
lineHeight = (BASE_FONT_SIZE + 5).sp
|
||||
)
|
||||
|
||||
// Typography matching the iOS monospace design - using BASE_FONT_SIZE for consistency
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
@ -21,7 +41,7 @@ val Typography = Typography(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
lineHeight = (BASE_FONT_SIZE + 3).sp
|
||||
lineHeight = (BASE_FONT_SIZE + 6).sp
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
@ -35,12 +55,31 @@ val Typography = Typography(
|
||||
fontSize = (BASE_FONT_SIZE + 3).sp,
|
||||
lineHeight = (BASE_FONT_SIZE + 9).sp
|
||||
),
|
||||
// Previously unset, which leaked the Roboto default into onboarding + sheet titles.
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = (BASE_FONT_SIZE + 13).sp,
|
||||
lineHeight = (BASE_FONT_SIZE + 21).sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = (BASE_FONT_SIZE + 5).sp,
|
||||
lineHeight = (BASE_FONT_SIZE + 13).sp
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = (BASE_FONT_SIZE + 1).sp,
|
||||
lineHeight = (BASE_FONT_SIZE + 7).sp
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = (BASE_FONT_SIZE - 1).sp,
|
||||
lineHeight = (BASE_FONT_SIZE + 5).sp
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">اختيار صورة</string>
|
||||
|
||||
<!-- ورقة حول التطبيق -->
|
||||
<string name="about_tagline">دردشة شبكية لامركزية مع تشفير طرف إلى طرف</string>
|
||||
<string name="about_offline_mesh_title">دردشة شبكية دون اتصال</string>
|
||||
<string name="about_offline_mesh_desc">تواصل مباشرة عبر Bluetooth LE بدون إنترنت أو خوادم. تُعاد توجيه الرسائل عبر الأجهزة القريبة لتوسيع النطاق.</string>
|
||||
<string name="about_online_geohash_title">قنوات geohash عبر الإنترنت</string>
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">ছবি নির্বাচন করুন</string>
|
||||
|
||||
<!-- সম্পর্কে শিট -->
|
||||
<string name="about_tagline">এন্ড-টু-এন্ড এনক্রিপশন সহ বিকেন্দ্রীভূত মেশ চ্যাট</string>
|
||||
<string name="about_offline_mesh_title">অফলাইন মেশ চ্যাট</string>
|
||||
<string name="about_offline_mesh_desc">ইন্টারনেট বা সার্ভার ছাড়াই Bluetooth LE এর মাধ্যমে সরাসরি যোগাযোগ করুন। পরিসর বাড়ানোর জন্য কাছাকাছি ডিভাইসের মাধ্যমে বার্তা রিলে করা হয়।</string>
|
||||
<string name="about_online_geohash_title">অনলাইন জিওহ্যাশ চ্যানেল</string>
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">Bild auswählen</string>
|
||||
|
||||
<!-- Über‑Sheet -->
|
||||
<string name="about_tagline">dezentraler Mesh‑Chat mit Ende‑zu‑Ende‑Verschlüsselung</string>
|
||||
<string name="about_offline_mesh_title">Offline‑Mesh‑Chat</string>
|
||||
<string name="about_offline_mesh_desc">Kommuniziere direkt über Bluetooth LE ohne Internet oder Server. Nachrichten werden über nahe Geräte weitergeleitet, um die Reichweite zu erhöhen.</string>
|
||||
<string name="about_online_geohash_title">Online‑Geohash‑Kanäle</string>
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">Elegir imagen</string>
|
||||
|
||||
<!-- Hoja Acerca de -->
|
||||
<string name="about_tagline">chat mesh descentralizado con cifrado de extremo a extremo</string>
|
||||
<string name="about_offline_mesh_title">Chat mesh sin conexión</string>
|
||||
<string name="about_offline_mesh_desc">Comunícate directamente a través de Bluetooth LE sin internet o servidores. Los mensajes se retransmiten a través de dispositivos cercanos para extender el alcance.</string>
|
||||
<string name="about_online_geohash_title">Canales geohash en línea</string>
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">انتخاب تصویر</string>
|
||||
|
||||
<!-- برگهٔ درباره -->
|
||||
<string name="about_tagline">گفتوگوی مِش غیرمتمرکز با رمزگذاری سرتاسری</string>
|
||||
<string name="about_offline_mesh_title">گفتوگوی مِش آفلاین</string>
|
||||
<string name="about_offline_mesh_desc">بدون اینترنت یا سرور، مستقیماً از طریق Bluetooth LE ارتباط برقرار کنید. پیامها از طریق دستگاههای نزدیک رله میشوند تا بُرد افزایش یابد.</string>
|
||||
<string name="about_online_geohash_title">کانالهای geohash آنلاین</string>
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">Pumili ng larawan</string>
|
||||
|
||||
<!-- About sheet -->
|
||||
<string name="about_tagline">desentralisadong mesh messaging na may end‑to‑end encryption</string>
|
||||
<string name="about_offline_mesh_title">Offline Mesh Chat</string>
|
||||
<string name="about_offline_mesh_desc">Makipag‑usap direkta sa Bluetooth LE nang walang internet o server. Ang mga mensahe ay nire‑relay ng mga kalapit na device.</string>
|
||||
<string name="about_online_geohash_title">Online na Geohash na mga Channel</string>
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">Choisir une image</string>
|
||||
|
||||
<!-- Feuille À propos -->
|
||||
<string name="about_tagline">messagerie mesh décentralisée avec chiffrement de bout en bout</string>
|
||||
<string name="about_offline_mesh_title">Chat Mesh hors‑ligne</string>
|
||||
<string name="about_offline_mesh_desc">Communique directement via Bluetooth LE sans internet ni serveurs. Les messages sont relayés par les appareils proches pour étendre la portée.</string>
|
||||
<string name="about_online_geohash_title">Canaux Geohash en ligne</string>
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">छवि चुनें</string>
|
||||
|
||||
<!-- अबाउट शीट -->
|
||||
<string name="about_tagline">विकेन्द्रीकृत मेष चैट • एंड‑टू‑एंड एन्क्रिप्शन</string>
|
||||
<string name="about_offline_mesh_title">ऑफ़लाइन मेष चैट</string>
|
||||
<string name="about_offline_mesh_desc">इंटरनेट या सर्वर के बिना Bluetooth LE के माध्यम से सीधे संवाद करें। संदेशों को पास के उपकरणों के माध्यम से रिले किया जाता है ताकि पहुँच बढ़े।</string>
|
||||
<string name="about_online_geohash_title">ऑनलाइन geohash चैनल</string>
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">Pilih gambar</string>
|
||||
|
||||
<!-- Sheet tentang -->
|
||||
<string name="about_tagline">chat mesh terdesentralisasi dengan enkripsi end-to-end</string>
|
||||
<string name="about_offline_mesh_title">Chat Mesh Offline</string>
|
||||
<string name="about_offline_mesh_desc">Berkomunikasi langsung melalui Bluetooth LE tanpa Internet atau server. Pesan diteruskan melalui perangkat terdekat untuk memperluas jangkauan.</string>
|
||||
<string name="about_online_geohash_title">Channel Geohash Online</string>
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">Scegli immagine</string>
|
||||
|
||||
<!-- Schermata informazioni -->
|
||||
<string name="about_tagline">messaggistica mesh decentralizzata con crittografia end-to-end</string>
|
||||
<string name="about_offline_mesh_title">Chat mesh offline</string>
|
||||
<string name="about_offline_mesh_desc">Comunica direttamente tramite Bluetooth LE senza internet o server. I messaggi vengono inoltrati attraverso i dispositivi vicini per estendere la portata.</string>
|
||||
<string name="about_online_geohash_title">Canali geohash online</string>
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">画像を選択</string>
|
||||
|
||||
<!-- 概要シート -->
|
||||
<string name="about_tagline">分散型メッシュチャット・エンドツーエンド暗号化</string>
|
||||
<string name="about_offline_mesh_title">オフライン・メッシュチャット</string>
|
||||
<string name="about_offline_mesh_desc">インターネットやサーバーなしで、Bluetooth LE 経由で直接通信します。近隣のデバイスを経由してメッセージを中継し、到達範囲を広げます。</string>
|
||||
<string name="about_online_geohash_title">オンライン Geohash チャンネル</string>
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">სურათის არჩევა</string>
|
||||
|
||||
<!-- შიტი "შესახებ" -->
|
||||
<string name="about_tagline">დეცენტრალიზებული mesh-ჩათი ბოლო-მდე დაშიფვრით</string>
|
||||
<string name="about_offline_mesh_title">ოფლაინ mesh-ჩათი</string>
|
||||
<string name="about_offline_mesh_desc">ესაუბრეთ პირდაპირ Bluetooth LE-ის საშუალებით ინტერნეტის ან სერვერების გარეშე. შეტყობინებები გადაიგზავნება ახლომდებარე მოწყობილობებზე, რათა გაიზარდოს დაფარვა.</string>
|
||||
<string name="about_online_geohash_title">ონლაინ geohash არხები</string>
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">이미지 선택</string>
|
||||
|
||||
<!-- 정보 시트 -->
|
||||
<string name="about_tagline">종단간 암호화를 갖춘 탈중앙 메쉬 채팅</string>
|
||||
<string name="about_offline_mesh_title">오프라인 메쉬 채팅</string>
|
||||
<string name="about_offline_mesh_desc">인터넷이나 서버 없이 Bluetooth LE로 직접 소통합니다. 범위를 확장하기 위해 근처 기기를 통해 메시지가 중계됩니다.</string>
|
||||
<string name="about_online_geohash_title">온라인 geohash 채널</string>
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">Hifidy sary</string>
|
||||
|
||||
<!-- Momba ny takelaka -->
|
||||
<string name="about_tagline">hafatra mesh tsy misy foibe misy fandokoana farany amin\'ny farany</string>
|
||||
<string name="about_offline_mesh_title">Mesh Chat tsy an-tserasera</string>
|
||||
<string name="about_offline_mesh_desc">Mifandraisa mivantana amin\'ny alalan\'ny Bluetooth LE tsy misy Internet na mpizara. Ny hafatra dia mamindra amin\'ny alalan\'ny fitaovana akaiky mba hanitarana ny elanelana.</string>
|
||||
<string name="about_online_geohash_title">Fantsona Geohash an-tserasera</string>
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">तस्वीर छान्नुहोस्</string>
|
||||
|
||||
<!-- अबाउट शीट -->
|
||||
<string name="about_tagline">विकेन्द्रित मेष सन्देश अन्त्यदेखि अन्त्य इन्क्रिप्सनसहित</string>
|
||||
<string name="about_offline_mesh_title">अफलाइन मेष च्याट</string>
|
||||
<string name="about_offline_mesh_desc">इन्टरनेट वा सर्भर बिना सिधै Bluetooth LE मार्फत सञ्चार गर्नुहोस्। नजिकका यन्त्रहरूबाट सन्देश रिल레이 हुन्छ।</string>
|
||||
<string name="about_online_geohash_title">अनलाइन Geohash च्यानलहरू</string>
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">Afbeelding kiezen</string>
|
||||
|
||||
<!-- Info-sheet -->
|
||||
<string name="about_tagline">gedecentraliseerde meshcommunicatie met end-to-end-versleuteling</string>
|
||||
<string name="about_offline_mesh_title">Offline meshchat</string>
|
||||
<string name="about_offline_mesh_desc">Communiceer rechtstreeks via Bluetooth LE zonder internet of servers. Berichten worden doorgestuurd via apparaten in de buurt om het bereik te vergroten.</string>
|
||||
<string name="about_online_geohash_title">Online geohash-kanalen</string>
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">تصویر چُنو</string>
|
||||
|
||||
<!-- بارے شیٹ -->
|
||||
<string name="about_tagline">اینڈ ٹو اینڈ انکرپشن نال غیر مرکزی میش چیٹ</string>
|
||||
<string name="about_offline_mesh_title">آف لائن میش چیٹ</string>
|
||||
<string name="about_offline_mesh_desc">انٹرنیٹ یا سرور توں بغیر Bluetooth LE راہین سیدھا رابطہ کرو۔ رینج ودھاؤن لئی پیغام نیڑے ڈیوائساں راہین ریلے ہوندے نیں۔</string>
|
||||
<string name="about_online_geohash_title">آن لائن geohash چینل</string>
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">Escolher imagem</string>
|
||||
|
||||
<!-- Sobre -->
|
||||
<string name="about_tagline">chat mesh descentralizado com criptografia de ponta a ponta</string>
|
||||
<string name="about_offline_mesh_title">Chat mesh offline</string>
|
||||
<string name="about_offline_mesh_desc">Comunique diretamente via Bluetooth LE sem internet ou servidores. As mensagens são retransmitidas por dispositivos próximos para ampliar o alcance.</string>
|
||||
<string name="about_online_geohash_title">Canais geohash online</string>
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">Escolher imagem</string>
|
||||
|
||||
<!-- Folha sobre -->
|
||||
<string name="about_tagline">chat mesh descentralizado com encriptação ponto a ponto</string>
|
||||
<string name="about_offline_mesh_title">Chat mesh offline</string>
|
||||
<string name="about_offline_mesh_desc">Comunique diretamente via Bluetooth LE sem internet ou servidores. As mensagens são retransmitidas através de dispositivos próximos para estender o alcance.</string>
|
||||
<string name="about_online_geohash_title">Canais geohash online</string>
|
||||
|
||||
@ -86,7 +86,6 @@
|
||||
<string name="close_with_emoji">❌ Закрыть</string>
|
||||
<string name="pick_image">Выбрать изображение</string>
|
||||
|
||||
<string name="about_tagline">децентрализованный mesh‑чат со сквозным шифрованием</string>
|
||||
<string name="about_offline_mesh_title">Оффлайн Mesh‑чат</string>
|
||||
<string name="about_offline_mesh_desc">Общение напрямую по Bluetooth LE без интернета и серверов. Сообщения ретранслируются близлежащими устройствами.</string>
|
||||
<string name="about_online_geohash_title">Онлайн‑каналы Geohash</string>
|
||||
|
||||
@ -86,7 +86,6 @@
|
||||
<string name="close_with_emoji">❌ Stäng</string>
|
||||
<string name="pick_image">Välj bild</string>
|
||||
|
||||
<string name="about_tagline">decentraliserad mesh‑meddelanden med ända‑till‑ända‑kryptering</string>
|
||||
<string name="about_offline_mesh_title">Offline Mesh‑chatt</string>
|
||||
<string name="about_offline_mesh_desc">Kommunicera direkt via Bluetooth LE utan internet eller servrar. Meddelanden reläas av enheter i närheten.</string>
|
||||
<string name="about_online_geohash_title">Online Geohash‑kanaler</string>
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">เลือกภาพ</string>
|
||||
|
||||
<!-- แผ่นข้อมูลเกี่ยวกับ -->
|
||||
<string name="about_tagline">แชทเมชแบบกระจายศูนย์พร้อมการเข้ารหัสแบบปลายทางถึงปลายทาง</string>
|
||||
<string name="about_offline_mesh_title">แชทเมชออฟไลน์</string>
|
||||
<string name="about_offline_mesh_desc">สื่อสารโดยตรงผ่าน Bluetooth LE โดยไม่ใช้อินเทอร์เน็ตหรือเซิร์ฟเวอร์ ข้อความจะถูกส่งต่อผ่านอุปกรณ์ใกล้เคียงเพื่อขยายระยะ</string>
|
||||
<string name="about_online_geohash_title">ช่อง geohash ออนไลน์</string>
|
||||
|
||||
@ -86,7 +86,6 @@
|
||||
<string name="close_with_emoji">❌ Kapat</string>
|
||||
<string name="pick_image">Görsel seç</string>
|
||||
|
||||
<string name="about_tagline">uçtan uca şifrelemeli merkezsiz mesh mesajlaşma</string>
|
||||
<string name="about_offline_mesh_title">Çevrimdışı Mesh Sohbeti</string>
|
||||
<string name="about_offline_mesh_desc">İnternet/ sunucu olmadan doğrudan Bluetooth LE ile iletişim kur. Mesajlar yakındaki cihazlarca aktarılır.</string>
|
||||
<string name="about_online_geohash_title">Çevrimiçi Geohash Kanalları</string>
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">تصویر منتخب کریں</string>
|
||||
|
||||
<!-- تفصیلات شیٹ -->
|
||||
<string name="about_tagline">غیر مرکزی میش چیٹ • اینڈ‑ٹو‑اینڈ انکرپشن</string>
|
||||
<string name="about_offline_mesh_title">آف لائن میش چیٹ</string>
|
||||
<string name="about_offline_mesh_desc">انٹرنیٹ یا سرورز کے بغیر Bluetooth LE کے ذریعے براہ راست بات چیت کریں۔ پیغامات قریبی آلات کے ذریعے ریلے ہوتے ہیں تاکہ رسائی بڑھے۔</string>
|
||||
<string name="about_online_geohash_title">آن لائن geohash چینلز</string>
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">Chọn hình ảnh</string>
|
||||
|
||||
<!-- Sheet giới thiệu -->
|
||||
<string name="about_tagline">trò chuyện mesh phi tập trung với mã hóa đầu cuối</string>
|
||||
<string name="about_offline_mesh_title">Trò chuyện Mesh ngoại tuyến</string>
|
||||
<string name="about_offline_mesh_desc">Liên lạc trực tiếp qua Bluetooth LE mà không cần Internet hoặc máy chủ. Tin nhắn được chuyển tiếp qua các thiết bị gần để mở rộng phạm vi.</string>
|
||||
<string name="about_online_geohash_title">Kênh Geohash trực tuyến</string>
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
<string name="pick_image">选择图片</string>
|
||||
|
||||
<!-- 关于 -->
|
||||
<string name="about_tagline">去中心化 mesh 消息,端到端加密</string>
|
||||
<string name="about_offline_mesh_title">离线 Mesh 聊天</string>
|
||||
<string name="about_offline_mesh_desc">无需网络或服务器,通过 Bluetooth LE 直接通信。利用附近设备中继消息以扩展范围。</string>
|
||||
<string name="about_online_geohash_title">在线 Geohash 频道</string>
|
||||
|
||||
@ -109,7 +109,50 @@
|
||||
<string name="cd_pick_file">Pick file</string>
|
||||
|
||||
<!-- About sheet -->
|
||||
<string name="about_tagline">decentralized mesh messaging with end-to-end encryption</string>
|
||||
<string name="about_tagline">The sidegroup chat</string>
|
||||
|
||||
<!-- About sheet: tabs -->
|
||||
<string name="about_tab_how_to_use">How to use</string>
|
||||
<string name="about_tab_basic_info">Basic info</string>
|
||||
|
||||
<!-- About sheet: how-to-use instructions -->
|
||||
<string name="about_how_to_use_heading">How To Use</string>
|
||||
<string name="about_howto_nickname">Set your nickname by tapping it</string>
|
||||
<string name="about_howto_channels">Tap the channel name to change channels</string>
|
||||
<string name="about_howto_people">Tap the people icon to see who\'s in the channel</string>
|
||||
<string name="about_howto_bookmark">Bookmark a channel to keep it in your list</string>
|
||||
<string name="about_howto_mention">Type @ to mention someone</string>
|
||||
<string name="about_howto_commands">Type / to see the available commands</string>
|
||||
<string name="about_howto_panic">Triple-tap the logo to wipe all data</string>
|
||||
|
||||
<!-- About sheet: section labels -->
|
||||
<string name="about_section_about">About</string>
|
||||
<string name="about_section_theme">Theme</string>
|
||||
<string name="about_section_settings">Settings</string>
|
||||
|
||||
<!-- About sheet: feature list -->
|
||||
<string name="about_offline_comm_title">Offline Communication</string>
|
||||
<string name="about_offline_comm_desc">Chat over a Bluetooth mesh with no internet and no servers. Messages relay through nearby devices to extend range.</string>
|
||||
<string name="about_local_channels_title">Local Channels</string>
|
||||
<string name="about_local_channels_desc">Geohash channels connect you with people nearby, extending the mesh over public relays.</string>
|
||||
<string name="about_no_tracking_title">No Tracking</string>
|
||||
<string name="about_no_tracking_desc">No servers, accounts, or data collection.</string>
|
||||
<string name="about_ephemeral_title">Ephemeral Identity</string>
|
||||
<string name="about_ephemeral_desc">A new peer ID is generated regularly.</string>
|
||||
<string name="about_panic_title">Panic Mode</string>
|
||||
<string name="about_panic_desc">Triple-tap the logo to instantly clear all data.</string>
|
||||
|
||||
<!-- About sheet: security warning -->
|
||||
<string name="about_warning_title">Warning</string>
|
||||
<string name="about_warning_body">Private message security has not yet been fully audited. Do not use for critical situations until this warning disappears.</string>
|
||||
|
||||
<!-- About sheet: settings labels -->
|
||||
<string name="about_tor_title">Tor Network</string>
|
||||
<string name="about_difficulty">Difficulty</string>
|
||||
<string name="about_difficulty_value">%1$d bits • %2$s</string>
|
||||
<string name="about_tor_connected">Connected (%1$d%%)</string>
|
||||
<string name="about_tor_disconnected">Disconnected</string>
|
||||
|
||||
<string name="about_offline_mesh_title">Offline Mesh Chat</string>
|
||||
<string name="about_offline_mesh_desc">Communicate directly via Bluetooth LE without internet or servers. Messages relay through nearby devices to extend range.</string>
|
||||
<string name="about_online_geohash_title">Online Geohash Channels</string>
|
||||
@ -213,7 +256,24 @@
|
||||
|
||||
<!-- Location channels sheet -->
|
||||
<string name="location_channels_title">#location channels</string>
|
||||
<string name="location_channels_heading">Location Channels</string>
|
||||
<string name="location_channels_desc">chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. do not screenshot or share this screen to protect your privacy.</string>
|
||||
|
||||
<!-- Location channels sheet: sections and Tor routing -->
|
||||
<string name="section_bookmarked">Bookmarked</string>
|
||||
<string name="section_nearby">Nearby</string>
|
||||
<string name="location_tor_routing_title">Tor Routing</string>
|
||||
<string name="location_tor_routing_desc">Hides your IP for location channels.</string>
|
||||
<string name="badge_recommended">Recommended</string>
|
||||
<string name="remove_location_access">Remove location access</string>
|
||||
|
||||
<!-- People sheet -->
|
||||
<string name="people_count_title">People (%1$d)</string>
|
||||
<string name="section_on_location">On location</string>
|
||||
<string name="section_teleported_in">Teleported in</string>
|
||||
<string name="section_channels">Channels</string>
|
||||
<string name="section_mesh">Mesh</string>
|
||||
|
||||
<string name="grant_location_permission">grant location permission</string>
|
||||
<string name="location_permission_denied">location permission denied. enable in settings to use location channels.</string>
|
||||
|
||||
@ -378,7 +438,7 @@
|
||||
<string name="you_suffix"> (you)</string>
|
||||
<string name="pan_zoom_instruction">pan and zoom to select a geohash</string>
|
||||
<string name="select">select</string>
|
||||
<string name="type_a_message_placeholder">type a message...</string>
|
||||
<string name="type_a_message_placeholder">Type a message…</string>
|
||||
<string name="mention_suggestion_at">@%1$s</string>
|
||||
<string name="mention">mention</string>
|
||||
<string name="image_counter">%1$d / %2$d</string>
|
||||
|
||||
@ -1,10 +1,16 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.ui.theme.DarkBitchatPalette
|
||||
import com.bitchat.android.ui.theme.LightBitchatPalette
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatUIUtilsTest {
|
||||
@ -12,33 +18,298 @@ class ChatUIUtilsTest {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
private val palette = DarkBitchatPalette
|
||||
|
||||
private fun message(
|
||||
content: String,
|
||||
sender: String = "alice",
|
||||
powDifficulty: Int? = null,
|
||||
) = BitchatMessage(
|
||||
sender = sender,
|
||||
content = content,
|
||||
timestamp = Date(0),
|
||||
powDifficulty = powDifficulty,
|
||||
)
|
||||
|
||||
// MARK: - Timestamp metadata
|
||||
|
||||
@Test
|
||||
fun `text message metadata separates PoW badge with one space`() {
|
||||
val message = BitchatMessage(
|
||||
sender = "alice",
|
||||
content = "hello",
|
||||
timestamp = Date(0),
|
||||
powDifficulty = 12,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"00:00:00 ⛨12b",
|
||||
formatTextMessageMetadata(message, timeFormatter).text,
|
||||
formatTextMessageMetadata(message("hello", powDifficulty = 12), timeFormatter).text,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text message metadata omits non-positive PoW difficulty`() {
|
||||
val message = BitchatMessage(
|
||||
sender = "alice",
|
||||
content = "hello",
|
||||
timestamp = Date(0),
|
||||
powDifficulty = 0,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"00:00:00",
|
||||
formatTextMessageMetadata(message, timeFormatter).text,
|
||||
formatTextMessageMetadata(message("hello", powDifficulty = 0), timeFormatter).text,
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Body with inline trailing timestamp
|
||||
|
||||
@Test
|
||||
fun `body appends timestamp inline after the message text`() {
|
||||
val body = formatTextMessageBody(
|
||||
message = message("hello there"),
|
||||
currentUserNickname = "bob",
|
||||
palette = palette,
|
||||
timeFormatter = timeFormatter,
|
||||
)
|
||||
|
||||
assertEquals("hello there 00:00:00", body.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `body appends PoW badge after the inline timestamp`() {
|
||||
val body = formatTextMessageBody(
|
||||
message = message("mined", powDifficulty = 8),
|
||||
currentUserNickname = "bob",
|
||||
palette = palette,
|
||||
timeFormatter = timeFormatter,
|
||||
)
|
||||
|
||||
assertEquals("mined 00:00:00 ⛨8b", body.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `body can omit the timestamp for callers that render it separately`() {
|
||||
val body = formatTextMessageBody(
|
||||
message = message("hello there"),
|
||||
currentUserNickname = "bob",
|
||||
palette = palette,
|
||||
timeFormatter = timeFormatter,
|
||||
includeTimestamp = false,
|
||||
)
|
||||
|
||||
assertEquals("hello there", body.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `body renders plain text in the neutral palette color, not terminal green`() {
|
||||
val body = formatTextMessageBody(
|
||||
message = message("plain words"),
|
||||
currentUserNickname = "bob",
|
||||
palette = palette,
|
||||
timeFormatter = timeFormatter,
|
||||
includeTimestamp = false,
|
||||
)
|
||||
|
||||
val textStyle = body.spanStyles.first { it.start == 0 }
|
||||
assertEquals(palette.textPrimary, textStyle.item.color)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `body does not bold plain text for the sender's own messages`() {
|
||||
// Regression guard: the old renderer bolded the entire body when the message was yours,
|
||||
// and again when you were mentioned, which is what made busy channels unreadable.
|
||||
val body = formatTextMessageBody(
|
||||
message = message("my own words", sender = "bob"),
|
||||
currentUserNickname = "bob",
|
||||
palette = palette,
|
||||
timeFormatter = timeFormatter,
|
||||
includeTimestamp = false,
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
"no span should force a bold weight",
|
||||
body.spanStyles.none { it.item.fontWeight?.weight?.let { w -> w >= 700 } == true }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Mention chips
|
||||
|
||||
private fun mentionChipSpans(body: androidx.compose.ui.text.AnnotatedString) =
|
||||
body.spanStyles.filter { it.item.background.isSpecified() }
|
||||
|
||||
private fun androidx.compose.ui.graphics.Color.isSpecified() =
|
||||
this != androidx.compose.ui.graphics.Color.Unspecified && alpha > 0f
|
||||
|
||||
@Test
|
||||
fun `mention renders as a single contiguous background chip`() {
|
||||
val body = formatTextMessageBody(
|
||||
message = message("hey @carol#04af what's up"),
|
||||
currentUserNickname = "bob",
|
||||
palette = palette,
|
||||
timeFormatter = timeFormatter,
|
||||
includeTimestamp = false,
|
||||
)
|
||||
|
||||
val chips = mentionChipSpans(body)
|
||||
assertEquals("expected exactly one chip", 1, chips.size)
|
||||
|
||||
// The chip must cover "@carol#04af" as one run so it paints without seams.
|
||||
val chip = chips.single()
|
||||
assertEquals("@carol#04af", body.text.substring(chip.start, chip.end))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mention targeting the current user uses the orange accent`() {
|
||||
val body = formatTextMessageBody(
|
||||
message = message("ping @bob now"),
|
||||
currentUserNickname = "bob",
|
||||
palette = palette,
|
||||
timeFormatter = timeFormatter,
|
||||
includeTimestamp = false,
|
||||
)
|
||||
|
||||
val chip = mentionChipSpans(body).single()
|
||||
assertEquals(palette.accentOrange.copy(alpha = MENTION_CHIP_ALPHA_SELF), chip.item.background)
|
||||
|
||||
val nameStyle: SpanStyle = body.spanStyles
|
||||
.first { it.start == chip.start && it.item.color == palette.accentOrange }
|
||||
.item
|
||||
assertEquals(palette.accentOrange, nameStyle.color)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mention of another user is tinted by that user's own peer color`() {
|
||||
val body = formatTextMessageBody(
|
||||
message = message("cc @carol#04af"),
|
||||
currentUserNickname = "bob",
|
||||
palette = palette,
|
||||
timeFormatter = timeFormatter,
|
||||
includeTimestamp = false,
|
||||
)
|
||||
|
||||
val expected = colorForPeerSeed("carol#04af", palette.isDark)
|
||||
val chip = mentionChipSpans(body).single()
|
||||
assertEquals(expected.copy(alpha = MENTION_CHIP_ALPHA), chip.item.background)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message without mentions has no background chips`() {
|
||||
val body = formatTextMessageBody(
|
||||
message = message("no mentions in here"),
|
||||
currentUserNickname = "bob",
|
||||
palette = palette,
|
||||
timeFormatter = timeFormatter,
|
||||
includeTimestamp = false,
|
||||
)
|
||||
|
||||
assertTrue(mentionChipSpans(body).isEmpty())
|
||||
}
|
||||
|
||||
// MARK: - System / action messages
|
||||
|
||||
@Test
|
||||
fun `system message uses a double-slash prefix and no brackets`() {
|
||||
val text = formatSystemMessage(
|
||||
message = message("Tor started. Routing all chats via Tor", sender = "system"),
|
||||
palette = palette,
|
||||
timeFormatter = timeFormatter,
|
||||
).text
|
||||
|
||||
assertEquals("// Tor started. Routing all chats via Tor 00:00:00", text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `system message is not italic`() {
|
||||
// The old treatment was `* italic asterisks *`, which competed visually with real
|
||||
// messages despite being lower-priority narration.
|
||||
val annotated = formatSystemMessage(
|
||||
message = message("tor restarting", sender = "system"),
|
||||
palette = palette,
|
||||
timeFormatter = timeFormatter,
|
||||
)
|
||||
|
||||
assertTrue(annotated.spanStyles.all { it.item.fontStyle == null })
|
||||
}
|
||||
|
||||
// MARK: - Sender label
|
||||
|
||||
@Test
|
||||
fun `sender label drops angle brackets and dims the hash suffix`() {
|
||||
val sender = formatTextMessageSender(
|
||||
message = message("hi", sender = "carol#04af"),
|
||||
currentUserNickname = "bob",
|
||||
meshService = FakeMeshServiceIds.stub(),
|
||||
palette = palette,
|
||||
)
|
||||
|
||||
assertEquals("@carol#04af", sender.text)
|
||||
|
||||
val suffixSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "#04af" }
|
||||
val nameSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "@carol" }
|
||||
assertNotNull(suffixSpan.item.color)
|
||||
assertTrue(
|
||||
"suffix must be dimmer than the name",
|
||||
suffixSpan.item.color.alpha < nameSpan.item.color.alpha
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sender label annotates the nickname for others but not for yourself`() {
|
||||
val other = formatTextMessageSender(
|
||||
message = message("hi", sender = "carol#04af"),
|
||||
currentUserNickname = "bob",
|
||||
meshService = FakeMeshServiceIds.stub(),
|
||||
palette = palette,
|
||||
)
|
||||
assertEquals(1, other.getStringAnnotations("nickname_click", 0, other.length).size)
|
||||
|
||||
val mine = formatTextMessageSender(
|
||||
message = message("hi", sender = "bob"),
|
||||
currentUserNickname = "bob",
|
||||
meshService = FakeMeshServiceIds.stub(),
|
||||
palette = palette,
|
||||
)
|
||||
assertTrue(mine.getStringAnnotations("nickname_click", 0, mine.length).isEmpty())
|
||||
}
|
||||
|
||||
// MARK: - Peer colors
|
||||
|
||||
@Test
|
||||
fun `peer color hue is stable across light and dark, only chroma differs`() {
|
||||
// Hue derivation must stay byte-identical to iOS; only saturation/value are tuned for
|
||||
// the redesigned neutral message body.
|
||||
val dark = colorForPeerSeed("noise:abc", isDark = true)
|
||||
val light = colorForPeerSeed("noise:abc", isDark = false)
|
||||
|
||||
val darkHsv = FloatArray(3)
|
||||
val lightHsv = FloatArray(3)
|
||||
rgbToHsv(dark.red, dark.green, dark.blue, darkHsv)
|
||||
rgbToHsv(light.red, light.green, light.blue, lightHsv)
|
||||
|
||||
assertEquals(darkHsv[0].toDouble(), lightHsv[0].toDouble(), 1.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `peer color avoids the orange hue reserved for self`() {
|
||||
// Sweep a range of seeds; none may land within the reserved orange band.
|
||||
repeat(500) { i ->
|
||||
val color = colorForPeerSeed("noise:seed$i", isDark = true)
|
||||
val hsv = FloatArray(3)
|
||||
rgbToHsv(color.red, color.green, color.blue, hsv)
|
||||
val distanceFromOrange = kotlin.math.abs(hsv[0] - 30f)
|
||||
assertTrue(
|
||||
"seed$i resolved to ${hsv[0]}°, inside the reserved orange band",
|
||||
distanceFromOrange >= 17f || hsv[1] < 0.01f
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `light palette is not the dark palette`() {
|
||||
assertNull(null)
|
||||
assertTrue(LightBitchatPalette.textPrimary != DarkBitchatPalette.textPrimary)
|
||||
assertTrue(LightBitchatPalette.isDark != DarkBitchatPalette.isDark)
|
||||
}
|
||||
|
||||
private fun rgbToHsv(r: Float, g: Float, b: Float, out: FloatArray) {
|
||||
val max = maxOf(r, g, b)
|
||||
val min = minOf(r, g, b)
|
||||
val delta = max - min
|
||||
out[0] = when {
|
||||
delta == 0f -> 0f
|
||||
max == r -> (60f * (((g - b) / delta) % 6f) + 360f) % 360f
|
||||
max == g -> 60f * (((b - r) / delta) + 2f)
|
||||
else -> 60f * (((r - g) / delta) + 4f)
|
||||
}
|
||||
out[1] = if (max == 0f) 0f else delta / max
|
||||
out[2] = max
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user