diff --git a/wear/src/main/java/com/bitchat/watch/ui/BottomBarVisibility.kt b/wear/src/main/java/com/bitchat/watch/ui/BottomBarVisibility.kt new file mode 100644 index 00000000..03943ea0 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/BottomBarVisibility.kt @@ -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 { + 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 +} 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 a1b31a80..fbd47989 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt @@ -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(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( + 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) + ) + } } } } 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 72c3b315..e4f7bb2d 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt @@ -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(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 -> diff --git a/wear/src/main/java/com/bitchat/watch/ui/NoiseLockIcon.kt b/wear/src/main/java/com/bitchat/watch/ui/NoiseLockIcon.kt new file mode 100644 index 00000000..2a4bc6b6 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/NoiseLockIcon.kt @@ -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) + ) +}