diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt new file mode 100644 index 00000000..442665ae --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -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, + 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, + 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) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt index eaf42023..b9aa013d 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt @@ -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(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) diff --git a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt index b91ccb3c..7f0bfa53 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt @@ -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(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) + ) + } +}