mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
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:
parent
e03e373caa
commit
5797ee18c0
@ -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
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 ->
|
||||
|
||||
76
wear/src/main/java/com/bitchat/watch/ui/NoiseLockIcon.kt
Normal file
76
wear/src/main/java/com/bitchat/watch/ui/NoiseLockIcon.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user