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
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.Column
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.offset
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.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -18,14 +24,19 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.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.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.lazy.items
import androidx.wear.compose.material3.Icon
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.Text
@ -53,7 +64,9 @@ fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) {
val unreadDms by WearChatState.unreadDms.collectAsState()
val mesh = WearMeshService.peek()
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 haptics = LocalHapticFeedback.current
var viewerPath by remember { mutableStateOf<String?>(null) }
@ -68,71 +81,99 @@ fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) {
if (last != null && last.senderPeerID != myPeerID) {
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()) {
listState.animateScrollToItem(0)
scrollState.animateScrollTo(scrollState.maxValue)
}
}
previousCount = messages.size
}
androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxSize()) {
ScreenScaffold(scrollState = listState) {
// Bottom padding keeps the last message just above the action bar, so messages
// scroll all the way down to the buttons.
// LazyColumn + reverseLayout: newest message anchors at the bottom above the action
// bar; empty space collects at the top (ScalingLazyColumn center-anchors short
// content, which left an awkward gap above the buttons).
androidx.compose.foundation.lazy.LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
reverseLayout = true,
contentPadding = androidx.compose.foundation.layout.PaddingValues(
top = 40.dp,
bottom = 56.dp
)
) {
items(messages.asReversed(), key = { it.id }) { message ->
MessageItem(
message = message,
myPeerID = myPeerID,
onOpenImage = { viewerPath = it }
)
}
if (messages.isEmpty()) {
item {
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)
)
val buttonsVisible = rememberBottomBarVisibility(scrollState)
val headerExpanded = scrollState.maxValue - scrollState.value > 60
Column(modifier = Modifier.fillMaxSize()) {
// Sticky header
ChatHeader(
peerCount = peers.size,
unreadDms = unreadDms.values.sum(),
expanded = headerExpanded,
onOpenPeople = onOpenPeople
)
androidx.compose.foundation.layout.Box(
modifier = Modifier
.fillMaxSize()
.weight(1f)
) {
// Normal top-down scrollable (natural rotary direction + ScreenScaffold scrollbar).
// BottomCenter alignment anchors short content to the bottom above the action bar;
// empty space collects at the top instead of a gap above the buttons.
ScreenScaffold(scrollState = scrollState) {
androidx.compose.foundation.layout.Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.BottomCenter
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 56.dp)
.rotaryScrollable(
RotaryScrollableDefaults.behavior(scrollState),
rotaryFocus
)
.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 ->
@ -141,33 +182,72 @@ fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) {
}
@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(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
.padding(horizontal = 8.dp, vertical = vPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "bitchat",
style = MaterialTheme.typography.titleSmall,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) { titleSize.toSp() },
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = " · $peerCount online >",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { onOpenPeople() }
)
if (unreadDms > 0) {
Text(
text = " · $unreadDms new",
style = MaterialTheme.typography.bodySmall,
color = LocalBitchatPalette.current.accentOrange,
modifier = Modifier.clickable { onOpenPeople() }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(start = 8.dp)
.clickable { onOpenPeople() }
) {
Icon(
imageVector = Icons.Filled.People,
contentDescription = "people",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(iconSize)
)
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
import androidx.compose.foundation.focusable
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@ -15,8 +19,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
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.style.TextAlign
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.watch.mesh.WearMeshService
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.LocalBitchatPalette
import com.bitchat.watch.ui.theme.colorForPeer
@ -38,7 +46,9 @@ fun DmScreen(peerID: String, onOpenTextInput: () -> Unit) {
val mesh = WearMeshService.peek()
val myPeerID = mesh?.myPeerID ?: ""
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
var viewerPath by remember { mutableStateOf<String?>(null) }
val voice = rememberVoiceNoteController { path ->
@ -73,78 +83,125 @@ fun DmScreen(peerID: String, onOpenTextInput: () -> Unit) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
}
if (messages.isNotEmpty()) {
listState.animateScrollToItem(0)
scrollState.animateScrollTo(scrollState.maxValue)
}
}
previousCount = messages.size
}
androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxSize()) {
ScreenScaffold(scrollState = listState) {
androidx.compose.foundation.lazy.LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
reverseLayout = true,
contentPadding = androidx.compose.foundation.layout.PaddingValues(
top = 40.dp,
bottom = 56.dp
)
) {
items(messages.asReversed(), key = { it.id }) { message ->
MessageItem(
message = message,
myPeerID = myPeerID,
onOpenImage = { viewerPath = it }
)
}
if (messages.isEmpty()) {
item {
Text(
text = if (sessionEstablished) "encrypted channel ready\nsay hi"
else "setting up encryption…",
style = ChatVisualTokens.SystemActionStyle,
color = palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
)
}
}
item {
Row(
val buttonsVisible = rememberBottomBarVisibility(scrollState)
val headerExpanded = scrollState.maxValue - scrollState.value > 60
val headerIconSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (headerExpanded) 16.dp else 11.dp,
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
label = "dmHdrIcon"
)
val headerTitleSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (headerExpanded) 15.dp else 11.dp,
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
label = "dmHdrTitle"
)
val headerVPadding by androidx.compose.animation.core.animateDpAsState(
targetValue = if (headerExpanded) 6.dp else 1.dp,
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
label = "dmHdrPad"
)
Column(modifier = Modifier.fillMaxSize()) {
// Sticky collapsing header: nickname + Noise lock (grey open → orange pulse → green)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = headerVPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = nickname,
style = MaterialTheme.typography.titleSmall,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
headerTitleSize.toSp()
},
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
.fillMaxWidth()
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
.padding(bottom = 56.dp)
.rotaryScrollable(
RotaryScrollableDefaults.behavior(scrollState),
rotaryFocus
)
.focusRequester(rotaryFocus)
.focusable()
.verticalScroll(scrollState)
) {
Text(
text = nickname,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = colorForPeer(nickname + peerID, palette)
)
Text(
text = if (sessionEstablished) " · noise ✓" else " · handshaking…",
style = MaterialTheme.typography.bodySmall,
color = if (sessionEstablished) MaterialTheme.colorScheme.primary
else palette.textTertiary
)
if (messages.isEmpty()) {
Text(
text = if (sessionEstablished) "encrypted channel ready\nsay hi"
else "setting up encryption…",
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 }
)
}
}
}
}
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 ->

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)
)
}