wear: fix inverted scroll direction and restore scrollbar - normal top-down scrollable bottom-anchored via Box alignment, ScreenScaffold scroll indicator, standard rotaryScrollable defaults

This commit is contained in:
callebtc 2026-07-28 21:30:08 +02:00
parent e03e373caa
commit 5797ee18c0
4 changed files with 378 additions and 131 deletions

View File

@ -0,0 +1,34 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.ScrollState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
/**
* Scroll-aware bottom-bar visibility (typical dynamic-hide pattern): the bar hides while the
* user scrolls into history and reappears when they scroll back toward the newest messages.
* Always visible at the bottom (newest).
*
* Lists are normal (top-down) scrollables: value 0 = oldest, maxValue = newest (visual bottom).
*/
@Composable
fun rememberBottomBarVisibility(scrollState: ScrollState): State<Boolean> {
val visible = remember { mutableStateOf(true) }
LaunchedEffect(scrollState) {
var last = 0
snapshotFlow { scrollState.value to scrollState.maxValue }.collect { (value, max) ->
val atNewest = max - value < 40
when {
atNewest -> visible.value = true
value < last - 24 -> visible.value = false // scrolling up into history
value > last + 24 -> visible.value = true // back down toward newest
}
last = value
}
}
return visible
}

View File

@ -1,6 +1,8 @@
package com.bitchat.watch.ui package com.bitchat.watch.ui
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -8,6 +10,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MailOutline
import androidx.compose.material.icons.filled.People
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -18,14 +24,19 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults
import androidx.wear.compose.foundation.rotary.rotaryScrollable
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow 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 androidx.compose.ui.unit.sp
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.wear.compose.material3.Icon
import androidx.wear.compose.material3.MaterialTheme import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.Text import androidx.wear.compose.material3.Text
@ -53,7 +64,9 @@ fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) {
val unreadDms by WearChatState.unreadDms.collectAsState() val unreadDms by WearChatState.unreadDms.collectAsState()
val mesh = WearMeshService.peek() val mesh = WearMeshService.peek()
val myPeerID = mesh?.myPeerID ?: "" val myPeerID = mesh?.myPeerID ?: ""
val listState = androidx.compose.foundation.lazy.rememberLazyListState() val scrollState = androidx.compose.foundation.rememberScrollState()
val rotaryFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { rotaryFocus.requestFocus() }
val palette = LocalBitchatPalette.current val palette = LocalBitchatPalette.current
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
var viewerPath by remember { mutableStateOf<String?>(null) } var viewerPath by remember { mutableStateOf<String?>(null) }
@ -68,71 +81,99 @@ fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) {
if (last != null && last.senderPeerID != myPeerID) { if (last != null && last.senderPeerID != myPeerID) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) haptics.performHapticFeedback(HapticFeedbackType.LongPress)
} }
// Keep the newest message visible (index 0 in reverse layout) // Keep the newest message visible (bottom of a normal top-down scrollable)
if (messages.isNotEmpty()) { if (messages.isNotEmpty()) {
listState.animateScrollToItem(0) scrollState.animateScrollTo(scrollState.maxValue)
} }
} }
previousCount = messages.size previousCount = messages.size
} }
androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxSize()) { val buttonsVisible = rememberBottomBarVisibility(scrollState)
ScreenScaffold(scrollState = listState) { val headerExpanded = scrollState.maxValue - scrollState.value > 60
// Bottom padding keeps the last message just above the action bar, so messages
// scroll all the way down to the buttons. Column(modifier = Modifier.fillMaxSize()) {
// LazyColumn + reverseLayout: newest message anchors at the bottom above the action // Sticky header
// bar; empty space collects at the top (ScalingLazyColumn center-anchors short ChatHeader(
// content, which left an awkward gap above the buttons). peerCount = peers.size,
androidx.compose.foundation.lazy.LazyColumn( unreadDms = unreadDms.values.sum(),
state = listState, expanded = headerExpanded,
modifier = Modifier.fillMaxSize(), onOpenPeople = onOpenPeople
reverseLayout = true, )
contentPadding = androidx.compose.foundation.layout.PaddingValues( androidx.compose.foundation.layout.Box(
top = 40.dp, modifier = Modifier
bottom = 56.dp .fillMaxSize()
) .weight(1f)
) { ) {
items(messages.asReversed(), key = { it.id }) { message -> // Normal top-down scrollable (natural rotary direction + ScreenScaffold scrollbar).
MessageItem( // BottomCenter alignment anchors short content to the bottom above the action bar;
message = message, // empty space collects at the top instead of a gap above the buttons.
myPeerID = myPeerID, ScreenScaffold(scrollState = scrollState) {
onOpenImage = { viewerPath = it } androidx.compose.foundation.layout.Box(
) modifier = Modifier.fillMaxSize(),
} contentAlignment = Alignment.BottomCenter
if (messages.isEmpty()) { ) {
item { Column(
Text( modifier = Modifier
text = "no messages yet\nsay hi to the mesh", .fillMaxWidth()
style = ChatVisualTokens.SystemActionStyle, .padding(bottom = 56.dp)
color = palette.textTertiary, .rotaryScrollable(
textAlign = TextAlign.Center, RotaryScrollableDefaults.behavior(scrollState),
modifier = Modifier rotaryFocus
.fillMaxWidth() )
.padding(vertical = 16.dp) .focusRequester(rotaryFocus)
) .focusable()
.verticalScroll(scrollState)
) {
if (messages.isEmpty()) {
Text(
text = "no messages yet\nsay hi to the mesh",
style = ChatVisualTokens.SystemActionStyle,
color = palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
)
}
messages.forEach { message ->
MessageItem(
message = message,
myPeerID = myPeerID,
onOpenImage = { viewerPath = it }
)
}
} }
} }
item {
ChatHeader(
peerCount = peers.size,
unreadDms = unreadDms.values.sum(),
onOpenPeople = onOpenPeople
)
}
} }
// Scroll-aware action bar: hides while scrolling into history, returns when
// scrolling back toward the newest messages.
androidx.compose.animation.AnimatedVisibility(
visible = buttonsVisible.value,
modifier = Modifier.align(Alignment.BottomCenter),
enter = androidx.compose.animation.slideInVertically(
initialOffsetY = { it },
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS)
) + androidx.compose.animation.fadeIn(
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS)
),
exit = androidx.compose.animation.slideOutVertically(
targetOffsetY = { it },
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS)
) + androidx.compose.animation.fadeOut(
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS)
)
) {
ChatActionBar(
onKeyboard = onOpenTextInput,
voice = voice,
modifier = Modifier.padding(bottom = 10.dp)
)
}
VoiceRecordOverlay(voice)
} }
// Always-visible action bar (the framework's edgeButton slot auto-hides on scroll,
// which would make push-to-talk unreachable mid-conversation).
ChatActionBar(
onKeyboard = onOpenTextInput,
voice = voice,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 10.dp)
)
VoiceRecordOverlay(voice)
} }
viewerPath?.let { path -> viewerPath?.let { path ->
@ -141,33 +182,72 @@ fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) {
} }
@Composable @Composable
private fun ChatHeader(peerCount: Int, unreadDms: Int, onOpenPeople: () -> Unit) { private fun ChatHeader(
peerCount: Int,
unreadDms: Int,
expanded: Boolean,
onOpenPeople: () -> Unit
) {
// Collapsing header: dense (small title, tiny icons) at the newest messages so the chat
// gets maximum space; scales up smoothly when the user scrolls into history.
val spec = androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
BitchatMotion.STANDARD_MS
)
val iconSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 16.dp else 11.dp, animationSpec = spec, label = "hdrIcon"
)
val titleSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 15.dp else 11.dp, animationSpec = spec, label = "hdrTitle"
)
val vPadding by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 6.dp else 1.dp, animationSpec = spec, label = "hdrPad"
)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp), .padding(horizontal = 8.dp, vertical = vPadding),
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = "bitchat", text = "bitchat",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) { titleSize.toSp() },
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
Text( Row(
text = " · $peerCount online >", verticalAlignment = Alignment.CenterVertically,
style = MaterialTheme.typography.bodySmall, modifier = Modifier
color = MaterialTheme.colorScheme.primary, .padding(start = 8.dp)
modifier = Modifier.clickable { onOpenPeople() } .clickable { onOpenPeople() }
) ) {
if (unreadDms > 0) { Icon(
Text( imageVector = Icons.Filled.People,
text = " · $unreadDms new", contentDescription = "people",
style = MaterialTheme.typography.bodySmall, tint = MaterialTheme.colorScheme.primary,
color = LocalBitchatPalette.current.accentOrange, modifier = Modifier.size(iconSize)
modifier = Modifier.clickable { onOpenPeople() }
) )
Text(
text = "$peerCount",
style = MaterialTheme.typography.bodySmall,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
(iconSize.value * 0.85f).dp.toSp()
},
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 2.dp)
)
if (unreadDms > 0) {
Icon(
imageVector = Icons.Filled.MailOutline,
contentDescription = "$unreadDms unread messages",
tint = LocalBitchatPalette.current.accentOrange,
modifier = Modifier
.padding(start = 5.dp)
.size(iconSize)
)
}
} }
} }
} }

View File

@ -1,10 +1,14 @@
package com.bitchat.watch.ui package com.bitchat.watch.ui
import androidx.compose.foundation.focusable
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -15,8 +19,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults
import androidx.wear.compose.foundation.rotary.rotaryScrollable
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -27,6 +34,7 @@ import androidx.wear.compose.material3.Text
import com.bitchat.android.services.AppStateStore import com.bitchat.android.services.AppStateStore
import com.bitchat.watch.mesh.WearMeshService import com.bitchat.watch.mesh.WearMeshService
import com.bitchat.watch.ui.media.FullScreenImageViewer import com.bitchat.watch.ui.media.FullScreenImageViewer
import com.bitchat.watch.ui.theme.BitchatMotion
import com.bitchat.watch.ui.theme.ChatVisualTokens import com.bitchat.watch.ui.theme.ChatVisualTokens
import com.bitchat.watch.ui.theme.LocalBitchatPalette import com.bitchat.watch.ui.theme.LocalBitchatPalette
import com.bitchat.watch.ui.theme.colorForPeer import com.bitchat.watch.ui.theme.colorForPeer
@ -38,7 +46,9 @@ fun DmScreen(peerID: String, onOpenTextInput: () -> Unit) {
val mesh = WearMeshService.peek() val mesh = WearMeshService.peek()
val myPeerID = mesh?.myPeerID ?: "" val myPeerID = mesh?.myPeerID ?: ""
val palette = LocalBitchatPalette.current val palette = LocalBitchatPalette.current
val listState = androidx.compose.foundation.lazy.rememberLazyListState() val scrollState = androidx.compose.foundation.rememberScrollState()
val rotaryFocus = remember { androidx.compose.ui.focus.FocusRequester() }
LaunchedEffect(Unit) { rotaryFocus.requestFocus() }
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
var viewerPath by remember { mutableStateOf<String?>(null) } var viewerPath by remember { mutableStateOf<String?>(null) }
val voice = rememberVoiceNoteController { path -> val voice = rememberVoiceNoteController { path ->
@ -73,78 +83,125 @@ fun DmScreen(peerID: String, onOpenTextInput: () -> Unit) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) haptics.performHapticFeedback(HapticFeedbackType.LongPress)
} }
if (messages.isNotEmpty()) { if (messages.isNotEmpty()) {
listState.animateScrollToItem(0) scrollState.animateScrollTo(scrollState.maxValue)
} }
} }
previousCount = messages.size previousCount = messages.size
} }
androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxSize()) { val buttonsVisible = rememberBottomBarVisibility(scrollState)
ScreenScaffold(scrollState = listState) { val headerExpanded = scrollState.maxValue - scrollState.value > 60
androidx.compose.foundation.lazy.LazyColumn( val headerIconSize by androidx.compose.animation.core.animateDpAsState(
state = listState, targetValue = if (headerExpanded) 16.dp else 11.dp,
modifier = Modifier.fillMaxSize(), animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
reverseLayout = true, label = "dmHdrIcon"
contentPadding = androidx.compose.foundation.layout.PaddingValues( )
top = 40.dp, val headerTitleSize by androidx.compose.animation.core.animateDpAsState(
bottom = 56.dp targetValue = if (headerExpanded) 15.dp else 11.dp,
) animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
) { label = "dmHdrTitle"
items(messages.asReversed(), key = { it.id }) { message -> )
MessageItem( val headerVPadding by androidx.compose.animation.core.animateDpAsState(
message = message, targetValue = if (headerExpanded) 6.dp else 1.dp,
myPeerID = myPeerID, animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
onOpenImage = { viewerPath = it } label = "dmHdrPad"
) )
}
if (messages.isEmpty()) { Column(modifier = Modifier.fillMaxSize()) {
item { // Sticky collapsing header: nickname + Noise lock (grey open → orange pulse → green)
Text( Row(
text = if (sessionEstablished) "encrypted channel ready\nsay hi" modifier = Modifier
else "setting up encryption…", .fillMaxWidth()
style = ChatVisualTokens.SystemActionStyle, .padding(horizontal = 8.dp, vertical = headerVPadding),
color = palette.textTertiary, horizontalArrangement = Arrangement.Center,
textAlign = TextAlign.Center, verticalAlignment = Alignment.CenterVertically
modifier = Modifier ) {
.fillMaxWidth() Text(
.padding(vertical = 16.dp) text = nickname,
) style = MaterialTheme.typography.titleSmall,
} fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
} headerTitleSize.toSp()
item { },
Row( fontWeight = FontWeight.Bold,
color = colorForPeer(nickname + peerID, palette)
)
NoiseLockIcon(
state = if (sessionEstablished) NoiseSessionUiState.Established
else NoiseSessionUiState.Handshaking,
size = headerIconSize,
modifier = Modifier.padding(start = 5.dp)
)
}
androidx.compose.foundation.layout.Box(
modifier = Modifier
.fillMaxSize()
.weight(1f)
) {
ScreenScaffold(scrollState = scrollState) {
androidx.compose.foundation.layout.Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.BottomCenter
) {
Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp), .padding(bottom = 56.dp)
horizontalArrangement = Arrangement.Center, .rotaryScrollable(
verticalAlignment = Alignment.CenterVertically RotaryScrollableDefaults.behavior(scrollState),
rotaryFocus
)
.focusRequester(rotaryFocus)
.focusable()
.verticalScroll(scrollState)
) { ) {
Text( if (messages.isEmpty()) {
text = nickname, Text(
style = MaterialTheme.typography.titleSmall, text = if (sessionEstablished) "encrypted channel ready\nsay hi"
fontWeight = FontWeight.Bold, else "setting up encryption…",
color = colorForPeer(nickname + peerID, palette) style = ChatVisualTokens.SystemActionStyle,
) color = palette.textTertiary,
Text( textAlign = TextAlign.Center,
text = if (sessionEstablished) " · noise ✓" else " · handshaking…", modifier = Modifier
style = MaterialTheme.typography.bodySmall, .fillMaxWidth()
color = if (sessionEstablished) MaterialTheme.colorScheme.primary .padding(vertical = 16.dp)
else palette.textTertiary )
) }
messages.forEach { message ->
MessageItem(
message = message,
myPeerID = myPeerID,
onOpenImage = { viewerPath = it }
)
}
} }
} }
} }
androidx.compose.animation.AnimatedVisibility(
visible = buttonsVisible.value,
modifier = Modifier.align(Alignment.BottomCenter),
enter = androidx.compose.animation.slideInVertically(
initialOffsetY = { it },
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS)
) + androidx.compose.animation.fadeIn(
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS)
),
exit = androidx.compose.animation.slideOutVertically(
targetOffsetY = { it },
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS)
) + androidx.compose.animation.fadeOut(
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS)
)
) {
ChatActionBar(
onKeyboard = onOpenTextInput,
voice = voice,
modifier = Modifier.padding(bottom = 10.dp)
)
}
VoiceRecordOverlay(voice)
} }
ChatActionBar(
onKeyboard = onOpenTextInput,
voice = voice,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 10.dp)
)
VoiceRecordOverlay(voice)
} }
viewerPath?.let { path -> viewerPath?.let { path ->

View File

@ -0,0 +1,76 @@
package com.bitchat.watch.ui
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.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import androidx.wear.compose.material3.Icon
import androidx.wear.compose.material3.MaterialTheme
import com.bitchat.watch.ui.theme.LocalBitchatPalette
enum class NoiseSessionUiState { Idle, Handshaking, Established }
/**
* Noise session lock icon, same visual language as the phone's NoiseSessionIcon: quiet grey
* open lock when idle, orange open lock with a soft pulse while the handshake is in flight,
* green closed lock once established. Tint and glyph transitions land together.
*/
@Composable
fun NoiseLockIcon(
state: NoiseSessionUiState,
modifier: Modifier = Modifier,
size: androidx.compose.ui.unit.Dp = 13.dp
) {
val palette = LocalBitchatPalette.current
val colorScheme = MaterialTheme.colorScheme
val targetTint = when (state) {
NoiseSessionUiState.Handshaking -> palette.accentOrange
NoiseSessionUiState.Established -> colorScheme.primary
NoiseSessionUiState.Idle -> colorScheme.onSurfaceVariant
}
val tint by animateColorAsState(
targetValue = targetTint,
animationSpec = tween(480, easing = FastOutSlowInEasing),
label = "noiseLockTint"
)
val pulseAlpha = if (state == NoiseSessionUiState.Handshaking) {
val transition = rememberInfiniteTransition(label = "noiseLockPulse")
transition.animateFloat(
initialValue = 0.45f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(600, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "noiseLockPulseAlpha"
).value
} else 1f
Icon(
imageVector = if (state == NoiseSessionUiState.Established) Icons.Filled.Lock
else Icons.Filled.LockOpen,
contentDescription = when (state) {
NoiseSessionUiState.Handshaking -> "handshake in progress"
NoiseSessionUiState.Established -> "encrypted"
NoiseSessionUiState.Idle -> "not encrypted yet"
},
tint = tint,
modifier = modifier
.size(size)
.alpha(pulseAlpha)
)
}