mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
Show ic_spec_lock_open while idle or handshaking, then Crossfade to the closed lock on success or failure — timed with the existing tint wash so the shackle settling reads as one smooth transition. Co-authored-by: Cursor <cursoragent@cursor.com>
867 lines
32 KiB
Kotlin
867 lines
32 KiB
Kotlin
package com.bitchat.android.ui
|
|
|
|
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.animation.Crossfade
|
|
import androidx.compose.animation.animateColorAsState
|
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
|
import androidx.compose.animation.core.RepeatMode
|
|
import androidx.compose.animation.core.animateFloat
|
|
import androidx.compose.animation.core.animateFloatAsState
|
|
import androidx.compose.animation.core.infiniteRepeatable
|
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
|
import androidx.compose.animation.core.tween
|
|
import androidx.compose.foundation.background
|
|
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.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.Brush
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.graphics.SolidColor
|
|
import androidx.compose.ui.graphics.graphicsLayer
|
|
import androidx.compose.ui.graphics.lerp
|
|
import androidx.compose.ui.graphics.vector.ImageVector
|
|
import androidx.compose.ui.graphics.painter.Painter
|
|
import androidx.compose.ui.platform.LocalFocusManager
|
|
import androidx.compose.ui.res.painterResource
|
|
import androidx.compose.ui.res.stringResource
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.text.input.ImeAction
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
import com.bitchat.android.ui.theme.BitchatFontFamily
|
|
import androidx.annotation.DrawableRes
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.foundation.layout.RowScope
|
|
import com.bitchat.android.R
|
|
import com.bitchat.android.core.ui.component.button.BitChatBrandButton
|
|
import com.bitchat.android.core.ui.component.button.CloseButton
|
|
import com.bitchat.android.net.ArtiTorManager
|
|
import com.bitchat.android.net.TorMode
|
|
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 44.dp tap targets fit properly. */
|
|
val ChatHeaderHeight = 52.dp
|
|
|
|
/**
|
|
* The single visible glyph size used by every icon in the top bar.
|
|
*
|
|
* The Figma header pairs 16 px icons with compact labels. At our 17sp header scale, 19dp preserves
|
|
* that icon-to-cap-height relationship without affecting the surrounding 44dp touch targets.
|
|
*/
|
|
internal val HeaderIconSize = 19.dp
|
|
|
|
/**
|
|
* Text size for the top bar's labels: nickname, channel name, peer count.
|
|
*
|
|
* A step up from the 15.sp body scale. The bar is the app's primary status readout and was
|
|
* noticeably harder to read than the messages below it; the extra point costs nothing because
|
|
* the bar's height is driven by [HeaderTapTarget], not by the text.
|
|
*/
|
|
private val HeaderTextSize = 17.sp
|
|
|
|
/** Minimum tap target for every interactive element in the header. */
|
|
private val HeaderTapTarget = 44.dp
|
|
|
|
/** Corner radius for the header's tappable label+icon clusters. */
|
|
private val HeaderClusterShape = RoundedCornerShape(8.dp)
|
|
|
|
/**
|
|
* Edge insets for the bar.
|
|
*
|
|
* Asymmetric because the leading glyph sits in a 44.dp tap target whose padding already supplies
|
|
* some optical inset, while the trailing action's does the same on the other side.
|
|
*/
|
|
internal val HeaderInsetStart = 12.dp
|
|
internal val HeaderInsetEnd = 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)
|
|
.pressScaleClickable(onClick = onClick, onClickLabel = contentDescription),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
content()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tor health for location-channel header glyphs.
|
|
*
|
|
* Status colours are heavily muted (blended into [normal]) so they read as a soft signal
|
|
* rather than an alarm. Connecting / not-yet-running also drives a slow glow pulse.
|
|
*/
|
|
internal data class TorConnectionVisual(
|
|
val tint: Color,
|
|
/** True while Tor is enabled but not fully bootstrapped — drives a pulse. */
|
|
val isProgress: Boolean,
|
|
)
|
|
|
|
@Composable
|
|
internal fun rememberTorConnectionVisual(normal: Color): TorConnectionVisual {
|
|
val palette = LocalBitchatPalette.current
|
|
val colorScheme = MaterialTheme.colorScheme
|
|
val torStatus by remember { ArtiTorManager.getInstance() }.statusFlow.collectAsState()
|
|
|
|
// ~28% of the loud accent mixed into the base tint keeps the hue without intensity.
|
|
val mutedConnecting = lerp(normal, palette.accentOrange, 0.28f)
|
|
val mutedFailed = lerp(normal, colorScheme.error, 0.30f)
|
|
|
|
val target = when {
|
|
torStatus.mode == TorMode.OFF -> TorConnectionVisual(normal, isProgress = false)
|
|
torStatus.running && torStatus.bootstrapPercent >= 100 ->
|
|
TorConnectionVisual(normal, isProgress = false)
|
|
torStatus.running -> TorConnectionVisual(mutedConnecting, isProgress = true)
|
|
else -> TorConnectionVisual(mutedFailed, isProgress = true)
|
|
}
|
|
|
|
val animatedTint by animateColorAsState(
|
|
targetValue = target.tint,
|
|
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
|
label = "torConnectionTint"
|
|
)
|
|
return TorConnectionVisual(tint = animatedTint, isProgress = target.isProgress)
|
|
}
|
|
|
|
/**
|
|
* Soft, slow brightness pulse used while Tor is connecting. Keeps scale fixed so layout
|
|
* does not shift; only opacity / a faint halo breathe. Glow strength itself cross-fades so
|
|
* starting/stopping progress never pops.
|
|
*/
|
|
@Composable
|
|
internal fun TorAwareHeaderIcon(
|
|
imageVector: ImageVector,
|
|
tint: Color,
|
|
isProgress: Boolean,
|
|
contentDescription: String?,
|
|
modifier: Modifier = Modifier,
|
|
) {
|
|
val progressFade by animateFloatAsState(
|
|
targetValue = if (isProgress) 1f else 0f,
|
|
animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing),
|
|
label = "torGlowFade"
|
|
)
|
|
val pulse = if (progressFade > 0.01f) {
|
|
val transition = rememberInfiniteTransition(label = "torGlow")
|
|
transition.animateFloat(
|
|
initialValue = 0.42f,
|
|
targetValue = 1f,
|
|
animationSpec = infiniteRepeatable(
|
|
animation = tween(durationMillis = 1800, easing = FastOutSlowInEasing),
|
|
repeatMode = RepeatMode.Reverse
|
|
),
|
|
label = "torGlowPulse"
|
|
).value
|
|
} else {
|
|
1f
|
|
}
|
|
|
|
// Fixed layout footprint = icon size. Glow is drawn larger via requiredSize so it never
|
|
// pushes neighbouring text when the pulse starts/stops.
|
|
Box(
|
|
contentAlignment = Alignment.Center,
|
|
modifier = modifier.size(HeaderIconSize)
|
|
) {
|
|
if (progressFade > 0.01f) {
|
|
val glowBrush = remember(tint) {
|
|
Brush.radialGradient(
|
|
colorStops = arrayOf(
|
|
0.0f to tint.copy(alpha = 0.55f),
|
|
0.45f to tint.copy(alpha = 0.22f),
|
|
1.0f to Color.Transparent,
|
|
)
|
|
)
|
|
}
|
|
Box(
|
|
modifier = Modifier
|
|
.requiredSize(HeaderIconSize + 14.dp)
|
|
.graphicsLayer { alpha = pulse * 0.85f * progressFade }
|
|
.background(glowBrush)
|
|
)
|
|
}
|
|
Icon(
|
|
imageVector = imageVector,
|
|
contentDescription = contentDescription,
|
|
modifier = Modifier
|
|
.size(HeaderIconSize)
|
|
.graphicsLayer {
|
|
// Idle = solid; in-progress = breathing opacity, lerped by [progressFade].
|
|
val breathing = 0.55f + pulse * 0.45f
|
|
alpha = 1f - progressFade * (1f - breathing)
|
|
},
|
|
tint = tint
|
|
)
|
|
}
|
|
}
|
|
|
|
/** Painter-resource counterpart used by the extracted Figma SVG family. */
|
|
@Composable
|
|
internal fun TorAwareHeaderIcon(
|
|
painter: Painter,
|
|
tint: Color,
|
|
isProgress: Boolean,
|
|
contentDescription: String?,
|
|
modifier: Modifier = Modifier,
|
|
) {
|
|
val progressFade by animateFloatAsState(
|
|
targetValue = if (isProgress) 1f else 0f,
|
|
animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing),
|
|
label = "torPainterGlowFade"
|
|
)
|
|
val pulse = if (progressFade > 0.01f) {
|
|
val transition = rememberInfiniteTransition(label = "torPainterGlow")
|
|
transition.animateFloat(
|
|
initialValue = 0.42f,
|
|
targetValue = 1f,
|
|
animationSpec = infiniteRepeatable(
|
|
animation = tween(durationMillis = 1800, easing = FastOutSlowInEasing),
|
|
repeatMode = RepeatMode.Reverse
|
|
),
|
|
label = "torPainterGlowPulse"
|
|
).value
|
|
} else {
|
|
1f
|
|
}
|
|
|
|
Box(
|
|
contentAlignment = Alignment.Center,
|
|
modifier = modifier.size(HeaderIconSize)
|
|
) {
|
|
if (progressFade > 0.01f) {
|
|
val glowBrush = remember(tint) {
|
|
Brush.radialGradient(
|
|
colorStops = arrayOf(
|
|
0.0f to tint.copy(alpha = 0.55f),
|
|
0.45f to tint.copy(alpha = 0.22f),
|
|
1.0f to Color.Transparent,
|
|
)
|
|
)
|
|
}
|
|
Box(
|
|
modifier = Modifier
|
|
.requiredSize(HeaderIconSize + 14.dp)
|
|
.graphicsLayer { alpha = pulse * 0.85f * progressFade }
|
|
.background(glowBrush)
|
|
)
|
|
}
|
|
Icon(
|
|
painter = painter,
|
|
contentDescription = contentDescription,
|
|
modifier = Modifier
|
|
.size(HeaderIconSize)
|
|
.graphicsLayer {
|
|
val breathing = 0.55f + pulse * 0.45f
|
|
alpha = 1f - progressFade * (1f - breathing)
|
|
},
|
|
tint = tint
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Noise session status for private-chat headers.
|
|
*
|
|
* Same visual language as the main header's Tor-aware globe: tint cross-fades between states,
|
|
* and a soft radial glow pulse while the handshake is in flight. The glyph itself is the open
|
|
* lock until a session is established (or fails), then the closed lock — both share the same
|
|
* baseline so a [Crossfade] reads as the shackle settling shut rather than an icon swap.
|
|
*/
|
|
@Composable
|
|
fun NoiseSessionIcon(
|
|
sessionState: String?,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
val palette = LocalBitchatPalette.current
|
|
val colorScheme = MaterialTheme.colorScheme
|
|
|
|
val (targetTint, isProgress, contentDescription) = when {
|
|
sessionState == "handshaking" -> Triple(
|
|
palette.accentOrange,
|
|
true,
|
|
stringResource(R.string.cd_handshake_in_progress)
|
|
)
|
|
sessionState == "established" -> Triple(
|
|
colorScheme.primary,
|
|
false,
|
|
stringResource(R.string.cd_encrypted)
|
|
)
|
|
sessionState?.startsWith("failed") == true -> Triple(
|
|
colorScheme.error,
|
|
false,
|
|
stringResource(R.string.cd_handshake_failed)
|
|
)
|
|
else -> Triple(
|
|
// Not yet started — quiet grey open lock.
|
|
colorScheme.onSurfaceVariant,
|
|
false,
|
|
stringResource(R.string.cd_ready_for_handshake)
|
|
)
|
|
}
|
|
|
|
// Closed once the handshake resolves (success or failure); open while idle or in flight.
|
|
val lockIconRes = when {
|
|
sessionState == "established" || sessionState?.startsWith("failed") == true ->
|
|
R.drawable.ic_spec_lock
|
|
else -> R.drawable.ic_spec_lock_open
|
|
}
|
|
|
|
// Match the tint wash so open → closed and grey → orange → green land together.
|
|
val lockTransitionMs = 480
|
|
|
|
val animatedTint by animateColorAsState(
|
|
targetValue = targetTint,
|
|
animationSpec = tween(durationMillis = lockTransitionMs, easing = FastOutSlowInEasing),
|
|
label = "noiseSessionTint"
|
|
)
|
|
|
|
Crossfade(
|
|
targetState = lockIconRes,
|
|
animationSpec = tween(durationMillis = lockTransitionMs, easing = FastOutSlowInEasing),
|
|
modifier = modifier,
|
|
label = "noiseLockGlyph"
|
|
) { iconRes ->
|
|
TorAwareHeaderIcon(
|
|
painter = painterResource(iconRes),
|
|
tint = animatedTint,
|
|
isProgress = isProgress,
|
|
contentDescription = contentDescription,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reachability glyph for a conversation, drawn from the same spec set the main header uses.
|
|
*
|
|
* Mirrors the main header's channel button: a globe for anything reached over the internet, the
|
|
* range mark for the local mesh, and the more specific transport glyph when we know it.
|
|
*/
|
|
@DrawableRes
|
|
internal fun conversationTransportIcon(
|
|
isReachedOverInternet: Boolean,
|
|
isWifiAware: Boolean,
|
|
isDirect: Boolean
|
|
): Int = when {
|
|
isReachedOverInternet -> R.drawable.ic_spec_globe
|
|
isWifiAware -> R.drawable.ic_spec_wifi
|
|
isDirect -> R.drawable.ic_spec_bluetooth
|
|
else -> R.drawable.ic_spec_routed
|
|
}
|
|
|
|
/**
|
|
* The shared chrome for a conversation header — private chats and channels alike.
|
|
*
|
|
* Deliberately built from the same tokens as [MainHeader] rather than from `TopAppBar`: identical
|
|
* height, identical 12/8.dp edge insets, the leading glyph in a [HeaderTapTarget]-sized slot so it
|
|
* lands exactly where the brand mark does, the same -6.dp optical nudge pulling the title toward
|
|
* that glyph, and the same [HeaderTextSize]. Anything less and the header visibly shifts as you
|
|
* move between the main timeline and a conversation.
|
|
*
|
|
* Actions are right-aligned and unweighted, so a long title yields space to them rather than
|
|
* pushing them off screen.
|
|
*/
|
|
@Composable
|
|
fun ConversationHeader(
|
|
@DrawableRes leadingIconRes: Int,
|
|
leadingIconTint: Color,
|
|
title: String,
|
|
modifier: Modifier = Modifier,
|
|
onTitleClick: (() -> Unit)? = null,
|
|
leadingContentDescription: String? = null,
|
|
actions: @Composable RowScope.() -> Unit = {}
|
|
) {
|
|
val colorScheme = MaterialTheme.colorScheme
|
|
|
|
Row(
|
|
modifier = modifier
|
|
.fillMaxWidth()
|
|
.height(ChatHeaderHeight)
|
|
.padding(start = HeaderInsetStart, end = HeaderInsetEnd),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Row(
|
|
modifier = Modifier.weight(1f),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Box(
|
|
modifier = Modifier.size(HeaderTapTarget),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Icon(
|
|
painter = painterResource(leadingIconRes),
|
|
contentDescription = leadingContentDescription,
|
|
modifier = Modifier.size(HeaderIconSize),
|
|
tint = leadingIconTint
|
|
)
|
|
}
|
|
|
|
// Same optical correction as the main header: the 44.dp tap target leaves more gap
|
|
// than the design wants between glyph and label.
|
|
Text(
|
|
text = title,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
fontSize = HeaderTextSize,
|
|
fontWeight = FontWeight.Medium,
|
|
color = colorScheme.primary,
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis,
|
|
modifier = Modifier
|
|
.offset(x = (-6).dp)
|
|
.then(
|
|
if (onTitleClick != null) {
|
|
Modifier
|
|
.clip(HeaderClusterShape)
|
|
.pressScaleClickable(onClick = onTitleClick)
|
|
.padding(horizontal = 6.dp, vertical = 4.dp)
|
|
} else {
|
|
Modifier
|
|
}
|
|
)
|
|
)
|
|
}
|
|
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
|
content = actions
|
|
)
|
|
}
|
|
}
|
|
|
|
/** An action slot in a [ConversationHeader], matching the main header's icon buttons. */
|
|
@Composable
|
|
fun ConversationHeaderAction(
|
|
onClick: () -> Unit,
|
|
contentDescription: String?,
|
|
modifier: Modifier = Modifier,
|
|
content: @Composable () -> Unit
|
|
) = HeaderIconButton(
|
|
onClick = onClick,
|
|
contentDescription = contentDescription,
|
|
modifier = modifier,
|
|
content = content
|
|
)
|
|
|
|
@Composable
|
|
fun NicknameEditor(
|
|
value: String,
|
|
onValueChange: (String) -> Unit,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
val colorScheme = MaterialTheme.colorScheme
|
|
val focusManager = LocalFocusManager.current
|
|
val scrollState = rememberScrollState()
|
|
|
|
// Auto-scroll to end when text changes (simulates cursor following)
|
|
LaunchedEffect(value) {
|
|
scrollState.animateScrollTo(scrollState.maxValue)
|
|
}
|
|
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = modifier
|
|
) {
|
|
Text(
|
|
text = stringResource(R.string.at_symbol),
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
fontSize = HeaderTextSize,
|
|
color = colorScheme.primary.copy(alpha = 0.7f)
|
|
)
|
|
|
|
BasicTextField(
|
|
value = value,
|
|
onValueChange = onValueChange,
|
|
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
|
color = colorScheme.primary,
|
|
fontFamily = BitchatFontFamily,
|
|
fontSize = HeaderTextSize
|
|
),
|
|
cursorBrush = SolidColor(colorScheme.primary),
|
|
singleLine = true,
|
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
keyboardActions = KeyboardActions(
|
|
onDone = {
|
|
focusManager.clearFocus()
|
|
}
|
|
),
|
|
modifier = Modifier
|
|
.widthIn(max = 150.dp)
|
|
.horizontalScroll(scrollState)
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun PeerCounter(
|
|
connectedPeers: List<String>,
|
|
joinedChannels: Set<String>,
|
|
hasUnreadChannels: Map<String, Int>,
|
|
isConnected: Boolean,
|
|
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
|
|
geohashPeople: List<GeoPerson>,
|
|
onClick: () -> Unit,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
val palette = LocalBitchatPalette.current
|
|
val colorScheme = MaterialTheme.colorScheme
|
|
|
|
// 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
|
|
Pair(count, if (count > 0) colorScheme.primary else palette.textTertiary)
|
|
}
|
|
is com.bitchat.android.geohash.ChannelID.Mesh,
|
|
null -> {
|
|
// Mesh channel: show Bluetooth-connected peers (excluding self)
|
|
val count = connectedPeers.size
|
|
Pair(count, if (isConnected && count > 0) colorScheme.secondary 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,
|
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
modifier = modifier
|
|
.clip(HeaderClusterShape)
|
|
.pressScaleClickable(onClick = onClick)
|
|
.height(HeaderTapTarget)
|
|
.padding(horizontal = 6.dp)
|
|
) {
|
|
Icon(
|
|
// The extracted people glyph stays legible at the compact header scale; the number
|
|
// beside it carries the precise count.
|
|
painter = painterResource(R.drawable.ic_spec_people),
|
|
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(HeaderIconSize),
|
|
tint = animatedCountColor
|
|
)
|
|
|
|
AnimatedCount(
|
|
count = peopleCount,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
fontSize = HeaderTextSize,
|
|
color = animatedCountColor,
|
|
fontWeight = FontWeight.Medium
|
|
)
|
|
|
|
if (joinedChannels.isNotEmpty()) {
|
|
AnimatedCount(
|
|
count = joinedChannels.size,
|
|
prefix = stringResource(R.string.channel_count_prefix),
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
fontSize = HeaderTextSize,
|
|
color = if (isConnected) colorScheme.primary else colorScheme.error,
|
|
fontWeight = FontWeight.Medium
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun ChatHeaderContent(
|
|
selectedPrivatePeer: String?,
|
|
currentChannel: String?,
|
|
nickname: String,
|
|
viewModel: ChatViewModel,
|
|
onBackClick: () -> Unit,
|
|
onSidebarClick: () -> Unit,
|
|
onTripleClick: () -> Unit,
|
|
onShowAppInfo: () -> Unit,
|
|
onLocationChannelsClick: () -> Unit,
|
|
onLocationNotesClick: () -> Unit
|
|
) {
|
|
val colorScheme = MaterialTheme.colorScheme
|
|
|
|
when {
|
|
currentChannel != null -> {
|
|
// Channel header
|
|
ChannelHeader(
|
|
channel = currentChannel,
|
|
onBackClick = onBackClick,
|
|
onLeaveChannel = { viewModel.leaveChannel(currentChannel) },
|
|
onSidebarClick = onSidebarClick
|
|
)
|
|
}
|
|
else -> {
|
|
// Main header
|
|
MainHeader(
|
|
nickname = nickname,
|
|
onNicknameChange = viewModel::setNickname,
|
|
onTitleClick = onShowAppInfo,
|
|
onTripleTitleClick = onTripleClick,
|
|
onSidebarClick = onSidebarClick,
|
|
onLocationChannelsClick = onLocationChannelsClick,
|
|
onLocationNotesClick = onLocationNotesClick,
|
|
viewModel = viewModel
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@Composable
|
|
private fun ChannelHeader(
|
|
channel: String,
|
|
onBackClick: () -> Unit,
|
|
onLeaveChannel: () -> Unit,
|
|
onSidebarClick: () -> Unit
|
|
) {
|
|
val colorScheme = MaterialTheme.colorScheme
|
|
|
|
// No back affordance: the close action on the right is the way out, exactly as in a private
|
|
// chat. Leaving the channel outright lives on its row in the network sheet, so it does not
|
|
// need a second, easily-mistaken home next to the exit.
|
|
ConversationHeader(
|
|
leadingIconRes = R.drawable.ic_spec_chat_bubbles,
|
|
leadingIconTint = colorScheme.primary,
|
|
leadingContentDescription = null,
|
|
title = "#$channel",
|
|
onTitleClick = onSidebarClick
|
|
) {
|
|
CloseButton(onClick = onBackClick)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun MainHeader(
|
|
nickname: String,
|
|
onNicknameChange: (String) -> Unit,
|
|
onTitleClick: () -> Unit,
|
|
onTripleTitleClick: () -> Unit,
|
|
onSidebarClick: () -> Unit,
|
|
onLocationChannelsClick: () -> Unit,
|
|
onLocationNotesClick: () -> Unit,
|
|
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()
|
|
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
|
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
|
|
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
|
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
|
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(ChatHeaderHeight)
|
|
.padding(start = HeaderInsetStart, end = HeaderInsetEnd),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
// MARK: - Identity cluster.
|
|
//
|
|
// Weighted so it yields space to the status cluster rather than pushing it off screen.
|
|
// Compose measures unweighted children first, so the icons on the right always get the
|
|
// width they need and a long nickname simply scrolls within what is left.
|
|
Row(
|
|
modifier = Modifier.weight(1f),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
BitChatBrandButton(
|
|
onClick = onTitleClick,
|
|
onTripleClick = onTripleTitleClick,
|
|
contentDescription = stringResource(R.string.cd_open_about),
|
|
modifier = Modifier.size(HeaderTapTarget),
|
|
)
|
|
|
|
// Nudge toward the brand glyph: the 44.dp tap target leaves more optical gap than
|
|
// spacing between the mark and the path label.
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.offset(x = (-6).dp)
|
|
) {
|
|
Text(
|
|
text = "/",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
fontSize = HeaderTextSize,
|
|
// 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(end = 2.dp)
|
|
)
|
|
|
|
NicknameEditor(
|
|
value = nickname,
|
|
onValueChange = onNicknameChange
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Status cluster.
|
|
//
|
|
// Order, left to right: unread DMs, notes, channel, people.
|
|
// Tor health is read from the location channel / notes icon colour rather than a
|
|
// dedicated status dot.
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
// Tight, because every child below is its own >=44.dp tap target.
|
|
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
|
) {
|
|
// Unread private messages badge (click to open most recent DM)
|
|
if (hasUnreadPrivateMessages.isNotEmpty()) {
|
|
HeaderIconButton(
|
|
onClick = { viewModel.openLatestUnreadPrivateChat() },
|
|
contentDescription = stringResource(R.string.cd_unread_private_messages)
|
|
) {
|
|
Icon(
|
|
painter = painterResource(R.drawable.ic_spec_envelope),
|
|
contentDescription = stringResource(R.string.cd_unread_private_messages),
|
|
modifier = Modifier.size(HeaderIconSize),
|
|
tint = palette.accentOrange
|
|
)
|
|
}
|
|
}
|
|
|
|
// Location notes + channel badge: one tight unit so the document glyph and the
|
|
// bluetooth/globe glyph sit at the same visual pitch as other header pairings.
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(0.dp)
|
|
) {
|
|
LocationNotesButton(
|
|
viewModel = viewModel,
|
|
onClick = onLocationNotesClick
|
|
)
|
|
|
|
// Bookmarking lives in the Location Channels sheet, one tap away via the channel
|
|
// button. Duplicating it here bought a shortcut for a rare action at the cost
|
|
// of a slot in the app's most crowded row.
|
|
|
|
LocationChannelsButton(
|
|
viewModel = viewModel,
|
|
onClick = onLocationChannelsClick
|
|
)
|
|
}
|
|
|
|
PeerCounter(
|
|
connectedPeers = connectedPeers.filter { it != viewModel.myPeerID },
|
|
joinedChannels = joinedChannels,
|
|
hasUnreadChannels = hasUnreadChannels,
|
|
isConnected = isConnected,
|
|
selectedLocationChannel = selectedLocationChannel,
|
|
geohashPeople = geohashPeople,
|
|
onClick = onSidebarClick
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Current channel indicator: a globe for geohash channels, a mesh 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
|
|
|
|
// Get current channel selection from location manager
|
|
val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
|
|
|
val isLocation = selectedChannel is com.bitchat.android.geohash.ChannelID.Location
|
|
val badgeText = when (val channel = selectedChannel) {
|
|
// Geohashes keep the '#' because that is how they are written and typed everywhere else.
|
|
is com.bitchat.android.geohash.ChannelID.Location -> "#${channel.channel.geohash}"
|
|
// The local mesh is not a hashtag channel, and the mesh glyph already says what it is,
|
|
// so it is plain "mesh".
|
|
else -> stringResource(R.string.mesh_label)
|
|
}
|
|
val channelColor = if (isLocation) colorScheme.primary else colorScheme.secondary
|
|
// Tor status only tints the globe (location channels). The local mesh stays blue.
|
|
val torVisual = if (isLocation) {
|
|
rememberTorConnectionVisual(normal = channelColor)
|
|
} else {
|
|
TorConnectionVisual(tint = channelColor, isProgress = false)
|
|
}
|
|
val badgeIconRes = if (isLocation) {
|
|
R.drawable.ic_spec_globe
|
|
} else {
|
|
R.drawable.ic_spec_range
|
|
}
|
|
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
modifier = Modifier
|
|
.clip(HeaderClusterShape)
|
|
.pressScaleClickable(
|
|
onClick = onClick,
|
|
onClickLabel = stringResource(R.string.location_channels_title)
|
|
)
|
|
.height(HeaderTapTarget)
|
|
// No start padding: the notes icon is paired directly to the left; keep end
|
|
// padding so the gap to PeerCounter matches other cluster separations.
|
|
.padding(start = 0.dp, end = 6.dp)
|
|
) {
|
|
TorAwareHeaderIcon(
|
|
painter = painterResource(badgeIconRes),
|
|
tint = torVisual.tint,
|
|
isProgress = torVisual.isProgress,
|
|
contentDescription = stringResource(R.string.cd_tor_status)
|
|
)
|
|
|
|
Text(
|
|
text = badgeText,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
fontSize = HeaderTextSize,
|
|
fontWeight = FontWeight.Medium,
|
|
color = channelColor,
|
|
maxLines = 1
|
|
)
|
|
}
|
|
}
|