wear: TransformingLazyColumn chat lists - native center-scaling/rotary/scrollbar, exact scroll-to-end autoscroll, Arrangement.Bottom anchoring, consolidated ChatScaffold

This commit is contained in:
callebtc 2026-07-28 22:06:51 +02:00
parent d4aa9b1644
commit fb93d2d85f
3 changed files with 309 additions and 266 deletions

View File

@ -0,0 +1,224 @@
package com.bitchat.watch.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState
import androidx.wear.compose.foundation.lazy.items
import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.Text
import androidx.wear.compose.material3.lazy.rememberTransformationSpec
import androidx.wear.compose.material3.lazy.transformedHeight
import com.bitchat.android.model.BitchatMessage
import com.bitchat.watch.ui.theme.BitchatMotion
import com.bitchat.watch.ui.theme.ChatVisualTokens
import com.bitchat.watch.ui.theme.LocalBitchatPalette
/**
* The shared chat body for global chat and DM threads: sticky collapsing header (via [header]),
* TransformingLazyColumn message list (native Wear center-scaling/fade, rotary, scrollbar),
* floating scroll-aware action bar, and the push-to-talk overlay.
*/
@Composable
fun ChatScaffold(
messages: List<BitchatMessage>,
myPeerID: String,
emptyText: String,
voice: VoiceNoteController,
onOpenImage: (String) -> Unit,
header: @Composable (expanded: Boolean) -> Unit,
actionBar: @Composable () -> Unit
) {
val palette = LocalBitchatPalette.current
val haptics = LocalHapticFeedback.current
val columnState = rememberTransformingLazyColumnState()
// Distance in px from the viewport's bottom edge to the end of the last item;
// Int.MAX_VALUE when the last message is not visible at all.
fun bottomDist(): Int {
if (messages.isEmpty()) return 0
val info = columnState.layoutInfo
val lastVisible = info.visibleItems.lastOrNull() ?: return Int.MAX_VALUE
if (lastVisible.index < messages.size - 1) return Int.MAX_VALUE
val viewportH = info.viewportSize.height
return viewportH - (lastVisible.offset + lastVisible.transformedHeight)
}
// Haptics on incoming messages.
var previousCount by remember { mutableStateOf(messages.size) }
LaunchedEffect(messages.size) {
if (messages.size > previousCount) {
val last = messages.lastOrNull()
if (last != null && last.senderPeerID != myPeerID) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
}
}
previousCount = messages.size
}
// Bottom clearance hysteresis: expand near the newest message so it sits comfortably above
// the floating buttons; collapse when reading history so text flows behind them.
// (Thresholds 40/120 straddle the 48dp padding delta, breaking the maxValue feedback loop.)
var padExpanded by remember { mutableStateOf(true) }
// Action bar hides while scrolling into history, returns toward the newest.
val buttonsVisible = remember { mutableStateOf(true) }
LaunchedEffect(columnState, messages.size) {
var lastPosition = 0
snapshotFlow {
val first = columnState.layoutInfo.visibleItems.firstOrNull()
(first?.index ?: 0) * 100_000 + (first?.offset ?: 0)
}.collect { position ->
val dist = bottomDist()
if (dist < 40) {
padExpanded = true
buttonsVisible.value = true
} else if (dist in 121..10_000) {
// Clearly reading history (MAX_VALUE = last item not laid out yet; transient
// right after a new message arrives, so it must not collapse the padding).
padExpanded = false
}
when {
dist >= 40 && position < lastPosition - 24 -> buttonsVisible.value = false
position > lastPosition + 24 -> buttonsVisible.value = true
}
lastPosition = position
}
}
// Stick to bottom: on new messages, follow to the last item while the user is near the
// bottom, and re-align whenever the bottom clearance expands (padding growth changes the
// scroll range). Gating on padExpanded (maintained by the layout collector above) avoids
// the stale-layoutInfo race of computing the distance here directly.
LaunchedEffect(columnState, messages.size, padExpanded) {
if (messages.isNotEmpty() && padExpanded) {
// scrollBy to the end of the range: animateScrollToItem stops as soon as the item
// is partially visible, which left the last message cropped behind the buttons.
columnState.scroll { scrollBy(Float.MAX_VALUE) }
}
}
val listBottomPadding by animateDpAsState(
targetValue = if (padExpanded) 56.dp else 8.dp,
animationSpec = tween(BitchatMotion.STANDARD_MS),
label = "listBottomPad"
)
// Header is dense near the newest messages, expands when reading history.
val headerExpanded = bottomDist() > 60
Column(modifier = Modifier.fillMaxSize()) {
header(headerExpanded)
ChatBody(
messages = messages,
myPeerID = myPeerID,
emptyText = emptyText,
voice = voice,
onOpenImage = onOpenImage,
columnState = columnState,
listBottomPadding = listBottomPadding,
buttonsVisible = buttonsVisible.value,
actionBar = actionBar,
modifier = Modifier.weight(1f)
)
}
}
@Composable
private fun ChatBody(
messages: List<BitchatMessage>,
myPeerID: String,
emptyText: String,
voice: VoiceNoteController,
onOpenImage: (String) -> Unit,
columnState: TransformingLazyColumnState,
listBottomPadding: androidx.compose.ui.unit.Dp,
buttonsVisible: Boolean,
actionBar: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
val transformationSpec = rememberTransformationSpec()
Box(modifier = modifier.fillMaxSize()) {
ScreenScaffold(scrollState = columnState) {
TransformingLazyColumn(
state = columnState,
modifier = Modifier.fillMaxSize(),
// Arrangement.Bottom anchors short content to the bottom: the first message
// starts just above the action bar and new messages push history upward.
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Bottom,
contentPadding = PaddingValues(bottom = listBottomPadding)
) {
if (messages.isEmpty()) {
item {
Text(
text = emptyText,
style = ChatVisualTokens.SystemActionStyle,
color = palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 48.dp)
)
}
}
items(messages, key = { it.id }) { message ->
MessageItem(
message = message,
myPeerID = myPeerID,
onOpenImage = onOpenImage,
modifier = Modifier
.transformedHeight(this, transformationSpec)
.graphicsLayer {
with(transformationSpec) {
applyContainerTransformation(scrollProgress)
}
}
)
}
}
}
AnimatedVisibility(
visible = buttonsVisible,
modifier = Modifier.align(Alignment.BottomCenter),
enter = slideInVertically(
initialOffsetY = { it },
animationSpec = tween(BitchatMotion.STANDARD_MS)
) + fadeIn(animationSpec = tween(BitchatMotion.STANDARD_MS)),
exit = slideOutVertically(
targetOffsetY = { it },
animationSpec = tween(BitchatMotion.STANDARD_MS)
) + fadeOut(animationSpec = tween(BitchatMotion.STANDARD_MS))
) {
Box(modifier = Modifier.padding(bottom = 10.dp)) {
actionBar()
}
}
VoiceRecordOverlay(voice)
}
}

View File

@ -64,135 +64,30 @@ fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) {
val unreadDms by WearChatState.unreadDms.collectAsState()
val mesh = WearMeshService.peek()
val myPeerID = mesh?.myPeerID ?: ""
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) }
val voice = rememberVoiceNoteController { path ->
mesh?.let { sendVoiceNote(it, null, path) }
}
var previousCount by remember { mutableStateOf(messages.size) }
LaunchedEffect(messages.size) {
if (messages.size > previousCount) {
val last = messages.lastOrNull()
if (last != null && last.senderPeerID != myPeerID) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
}
// Keep the newest message visible (bottom of a normal top-down scrollable)
if (messages.isNotEmpty()) {
scrollState.animateScrollTo(scrollState.maxValue)
}
ChatScaffold(
messages = messages,
myPeerID = myPeerID,
emptyText = "no messages yet\nsay hi to the mesh",
voice = voice,
onOpenImage = { viewerPath = it },
header = { expanded ->
ChatHeader(
peerCount = peers.size,
unreadDms = unreadDms.values.sum(),
expanded = expanded,
onOpenPeople = onOpenPeople
)
},
actionBar = {
ChatActionBar(onKeyboard = onOpenTextInput, voice = voice)
}
previousCount = messages.size
}
val buttonsVisible = rememberBottomBarVisibility(scrollState)
val headerExpanded = scrollState.maxValue - scrollState.value > 60
// Full bottom clearance only near the newest message, so the last message sits comfortably
// above the floating buttons; collapses when scrolling up so history flows behind them.
// Hysteresis (expand <40, collapse >120) breaks the feedback loop: the 48dp padding delta
// changes maxValue, which would otherwise retrigger the condition every frame.
var padExpanded by remember { mutableStateOf(true) }
LaunchedEffect(scrollState) {
androidx.compose.runtime.snapshotFlow { scrollState.maxValue - scrollState.value }
.collect { dist ->
if (dist < 40) padExpanded = true
else if (dist > 120) padExpanded = false
}
}
val listBottomPadding by androidx.compose.animation.core.animateDpAsState(
targetValue = if (padExpanded) 56.dp else 8.dp,
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
label = "listBottomPad"
)
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 = listBottomPadding)
.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(bottom = 48.dp)
)
}
messages.forEach { message ->
MessageItem(
message = message,
myPeerID = myPeerID,
onOpenImage = { viewerPath = it }
)
}
}
}
}
// 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)
}
}
viewerPath?.let { path ->
FullScreenImageViewer(path = path, onClose = { viewerPath = null })
}
@ -273,7 +168,8 @@ private fun ChatHeader(
fun MessageItem(
message: BitchatMessage,
myPeerID: String,
onOpenImage: (String) -> Unit = {}
onOpenImage: (String) -> Unit = {},
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
val isSelf = message.senderPeerID == myPeerID
@ -297,7 +193,7 @@ fun MessageItem(
)
Column(
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 3.dp)
.offset(y = offset)

View File

@ -46,10 +46,6 @@ fun DmScreen(peerID: String, onOpenTextInput: () -> Unit) {
val mesh = WearMeshService.peek()
val myPeerID = mesh?.myPeerID ?: ""
val palette = LocalBitchatPalette.current
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 ->
mesh?.let { sendVoiceNote(it, peerID, path) }
@ -75,149 +71,76 @@ fun DmScreen(peerID: String, onOpenTextInput: () -> Unit) {
}
}
var previousCount by remember { mutableStateOf(messages.size) }
LaunchedEffect(messages.size) {
if (messages.size > previousCount) {
val last = messages.lastOrNull()
if (last != null && last.senderPeerID != myPeerID) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
}
if (messages.isNotEmpty()) {
scrollState.animateScrollTo(scrollState.maxValue)
}
}
previousCount = messages.size
}
val buttonsVisible = rememberBottomBarVisibility(scrollState)
val headerExpanded = scrollState.maxValue - scrollState.value > 60
var padExpanded by remember { mutableStateOf(true) }
LaunchedEffect(scrollState) {
androidx.compose.runtime.snapshotFlow { scrollState.maxValue - scrollState.value }
.collect { dist ->
if (dist < 40) padExpanded = true
else if (dist > 120) padExpanded = false
}
}
val listBottomPadding by androidx.compose.animation.core.animateDpAsState(
targetValue = if (padExpanded) 56.dp else 8.dp,
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
label = "listBottomPad"
)
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)
ChatScaffold(
messages = messages,
myPeerID = myPeerID,
emptyText = if (sessionEstablished) "encrypted channel ready\nsay hi"
else "setting up encryption…",
voice = voice,
onOpenImage = { viewerPath = it },
header = { expanded ->
DmHeader(
nickname = nickname,
peerID = peerID,
sessionEstablished = sessionEstablished,
expanded = expanded
)
},
actionBar = {
ChatActionBar(onKeyboard = onOpenTextInput, voice = voice)
}
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(bottom = listBottomPadding)
.rotaryScrollable(
RotaryScrollableDefaults.behavior(scrollState),
rotaryFocus
)
.focusRequester(rotaryFocus)
.focusable()
.verticalScroll(scrollState)
) {
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(bottom = 48.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)
}
}
)
viewerPath?.let { path ->
FullScreenImageViewer(path = path, onClose = { viewerPath = null })
}
}
@Composable
private fun DmHeader(
nickname: String,
peerID: String,
sessionEstablished: Boolean,
expanded: Boolean
) {
val palette = LocalBitchatPalette.current
val headerIconSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 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 (expanded) 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 (expanded) 6.dp else 1.dp,
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
label = "dmHdrPad"
)
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)
)
}
}