mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
wear: TransformingLazyColumn chat lists - native center-scaling/rotary/scrollbar, exact scroll-to-end autoscroll, Arrangement.Bottom anchoring, consolidated ChatScaffold
This commit is contained in:
parent
d4aa9b1644
commit
fb93d2d85f
224
wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt
Normal file
224
wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user