Merge pull request #931 from permissionlesstech/codex/wear-ui-shape-accessibility

Improve Wear round-screen layouts and chat scrolling
This commit is contained in:
callebtc 2026-09-07 14:43:41 +03:00 committed by GitHub
commit f352a3e4bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 776 additions and 519 deletions

View File

@ -18,9 +18,6 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -30,7 +27,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@ -39,7 +35,7 @@ import androidx.core.content.ContextCompat
import androidx.wear.compose.material3.Button
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.Text
import androidx.wear.compose.material3.TextButton
import androidx.wear.compose.material3.OutlinedButton
import com.bitchat.watch.mesh.WearMeshService
import com.bitchat.watch.notification.WearNotificationCoordinator
import com.bitchat.watch.service.WearMeshForegroundService
@ -50,6 +46,7 @@ import com.bitchat.watch.ui.PeopleScreen
import com.bitchat.watch.ui.UserDetailScreen
import com.bitchat.watch.ui.VerificationCodeScreen
import com.bitchat.watch.ui.WearChatState
import com.bitchat.watch.ui.WearFormScreen
import com.bitchat.watch.ui.sendPrivateMessage
import com.bitchat.watch.ui.sendPublicMessage
import com.bitchat.watch.ui.theme.BitchatWearTheme
@ -460,104 +457,110 @@ internal fun WearNavHost(
@Composable
fun NotificationPermissionScreen(onResult: (Boolean) -> Unit, onSkip: () -> Unit) {
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> onResult(granted) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Message alerts",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Alerts for encrypted direct messages",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp, bottom = 10.dp)
)
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
onResult(true)
}
}
) {
Text("Enable")
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
onResult(granted)
}
TextButton(onClick = onSkip) {
Text("Not now")
WearFormScreen {
item {
Text(
text = "Message alerts",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
item {
Text(
text = "Alerts for encrypted direct messages",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp, bottom = 10.dp),
)
}
item {
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
onResult(true)
}
}
) {
Text("Enable")
}
}
item {
OutlinedButton(onClick = onSkip) {
Text("Not now")
}
}
}
}
@Composable
fun PermissionRequestScreen(onGranted: () -> Unit) {
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { onGranted() }
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
onGranted()
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "bitchat",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Needs Bluetooth to mesh with nearby devices",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp, bottom = 12.dp)
)
Button(onClick = {
launcher.launch(MainActivity.requiredPermissions().toTypedArray())
}) {
Text("Grant access")
WearFormScreen {
item {
Text(
text = "bitchat",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
item {
Text(
text = "Needs Bluetooth to mesh with nearby devices",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp, bottom = 12.dp),
)
}
item {
Button(
onClick = {
launcher.launch(MainActivity.requiredPermissions().toTypedArray())
}
) {
Text("Grant access", textAlign = TextAlign.Center)
}
}
}
}
@Composable
fun BluetoothEnableScreen(onEnabled: () -> Unit) {
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { onEnabled() }
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
onEnabled()
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Bluetooth is off",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface
)
Button(
onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) },
modifier = Modifier.padding(top = 10.dp)
) {
Text("Turn on")
WearFormScreen {
item {
Text(
text = "Bluetooth is off",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
}
item {
Button(
onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) },
modifier = Modifier.padding(top = 10.dp),
) {
Text("Turn on")
}
}
}
}

View File

@ -13,6 +13,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@ -36,6 +37,7 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@ -74,7 +76,7 @@ fun ChatActionBar(
) {
Box(
modifier = Modifier
.size(38.dp)
.size(48.dp)
.clip(CircleShape)
.background(palette.inputButton)
.clickable { onKeyboard() },
@ -89,7 +91,7 @@ fun ChatActionBar(
}
Box(
modifier = Modifier
.size(38.dp)
.size(48.dp)
.clip(CircleShape)
.background(
when {
@ -150,7 +152,7 @@ fun VoiceRecordOverlay(
hoveringCancel: Boolean,
proximity: Float,
magnetPull: Offset,
onCancelBounds: (androidx.compose.ui.geometry.Rect) -> Unit
onCancelBounds: (androidx.compose.ui.geometry.Rect) -> Unit,
) {
val palette = LocalBitchatPalette.current
// The cancel morph, choreographed for feel:
@ -159,101 +161,127 @@ fun VoiceRecordOverlay(
// - the button leans toward the approaching finger (magnetic pull), chasing it with a
// smooth spring so it lags and settles naturally
// - scale blooms with a soft bounce on activation — no rotation, no wobble
val cancelScale by androidx.compose.animation.core.animateFloatAsState(
targetValue = if (hoveringCancel) 1.32f else 1f + 0.1f * proximity,
animationSpec = androidx.compose.animation.core.spring(
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium
),
label = "cancelSnap"
)
val pull by androidx.compose.animation.core.animateOffsetAsState(
targetValue = magnetPull,
animationSpec = androidx.compose.animation.core.spring(
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium
),
label = "magnetPull"
)
val cancelColor = androidx.compose.ui.graphics.lerp(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.error,
if (hoveringCancel) 1f else proximity * 0.85f
)
val cancelScale by
androidx.compose.animation.core.animateFloatAsState(
targetValue = if (hoveringCancel) 1.32f else 1f + 0.1f * proximity,
animationSpec =
androidx.compose.animation.core.spring(
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium,
),
label = "cancelSnap",
)
val pull by
androidx.compose.animation.core.animateOffsetAsState(
targetValue = magnetPull,
animationSpec =
androidx.compose.animation.core.spring(
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium,
),
label = "magnetPull",
)
val cancelColor =
androidx.compose.ui.graphics.lerp(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.error,
if (hoveringCancel) 1f else proximity * 0.85f,
)
AnimatedVisibility(
visible = voice.recording,
enter = fadeIn(tween(BitchatMotion.EMPHASIZED_MS)),
exit = fadeOut(tween(BitchatMotion.EMPHASIZED_MS))
exit = fadeOut(tween(BitchatMotion.EMPHASIZED_MS)),
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.96f))
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
BoxWithConstraints(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.onGloballyPositioned { coords ->
onCancelBounds(
androidx.compose.ui.geometry.Rect(
coords.localToRoot(androidx.compose.ui.geometry.Offset.Zero),
coords.size.toSize()
)
)
}
.size(52.dp)
.graphicsLayer {
translationX = pull.x
translationY = pull.y
scaleX = cancelScale
scaleY = cancelScale
}
.clip(CircleShape)
.background(cancelColor),
contentAlignment = Alignment.Center
) {
androidx.compose.animation.Crossfade(
targetState = hoveringCancel,
animationSpec = tween(BitchatMotion.STANDARD_MS),
label = "cancelIcon"
) { cancel ->
Icon(
imageVector = if (cancel) Icons.Filled.Close else Icons.Filled.Mic,
contentDescription = if (cancel) "cancel recording" else null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(26.dp)
)
val side =
if (LocalConfiguration.current.isScreenRound) {
roundContentSide(maxWidth.value, maxHeight.value).dp
} else {
minOf(maxWidth, maxHeight) - 24.dp
}
Column(
modifier = Modifier.size(side),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
// Text gets its natural height first. The decorative waveform and cancel icon
// share the remaining room instead of pushing instructions off the display.
BoxWithConstraints(
Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
val iconSize = minOf(52.dp, maxHeight / 1.32f, maxWidth / 1.32f)
Box(
modifier =
Modifier.onGloballyPositioned { coords ->
onCancelBounds(
androidx.compose.ui.geometry.Rect(
coords.localToRoot(
androidx.compose.ui.geometry.Offset.Zero
),
coords.size.toSize(),
)
)
}
.size(iconSize)
.graphicsLayer {
translationX = pull.x
translationY = pull.y
scaleX = cancelScale
scaleY = cancelScale
}
.clip(CircleShape)
.background(cancelColor),
contentAlignment = Alignment.Center,
) {
androidx.compose.animation.Crossfade(
targetState = hoveringCancel,
animationSpec = tween(BitchatMotion.STANDARD_MS),
label = "cancelIcon",
) { cancel ->
Icon(
imageVector = if (cancel) Icons.Filled.Close else Icons.Filled.Mic,
contentDescription = if (cancel) "cancel recording" else null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(iconSize / 2),
)
}
}
}
WaveformBars(
samples = voice.liveSamples,
progress = 1f,
activeColor = MaterialTheme.colorScheme.primary,
inactiveColor = MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxWidth().height(12.dp),
)
Text(
text =
(if (voice.isLive) "LIVE " else "") +
"%d:%02d"
.format(
voice.elapsedMs / 1000 / 60,
voice.elapsedMs / 1000 % 60,
) +
"/0:10",
style = ChatVisualTokens.SystemActionStyle,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth(),
)
Text(
text = if (hoveringCancel) "Release to cancel" else "Lift finger to send",
style = ChatVisualTokens.SystemActionStyle,
color =
if (hoveringCancel) MaterialTheme.colorScheme.error
else palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
WaveformBars(
samples = voice.liveSamples,
progress = 1f,
activeColor = MaterialTheme.colorScheme.primary,
inactiveColor = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 16.dp)
.fillMaxWidth()
.height(44.dp)
)
Text(
text = (if (voice.isLive) "LIVE · " else "") + "%d:%02d".format(
voice.elapsedMs / 1000 / 60,
voice.elapsedMs / 1000 % 60
) + " / 0:10",
style = ChatVisualTokens.SenderStyle,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(top = 10.dp)
)
Text(
text = if (hoveringCancel) "Release to cancel" else "Lift finger to send",
style = ChatVisualTokens.SystemActionStyle,
color = if (hoveringCancel) MaterialTheme.colorScheme.error
else palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 2.dp)
)
}
}
}

View File

@ -28,14 +28,19 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState
import androidx.wear.compose.foundation.lazy.items
@ -50,13 +55,12 @@ import com.bitchat.watch.ui.theme.ChatVisualTokens
import com.bitchat.watch.ui.theme.LocalBitchatPalette
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlin.math.sign
/**
* The shared chat body for global chat and DM threads, following the classic messenger
* pattern: a TransformingLazyColumn message list (native Wear center-scaling/fade, rotary,
* scrollbar) with the header and action bar as floating overlays that get out of the way
* while scrolling up into history and return on any downward scroll; at the newest message
* while scrolling up into history and return on a deliberate reverse scroll; at the newest message
* they are always visible.
*
* The list's contentPadding is CONSTANT and both overlays are layout-neutral, so showing or
@ -92,33 +96,38 @@ fun ChatScaffold(
// Follow intent is changed only by an actual user scroll away from the newest item or by
// reaching the end again. A new item temporarily makes canScrollForward true before layout;
// treating that transient range change as user intent breaks automatic following.
var followNewest by remember { mutableStateOf(true) }
val controlsVisible = remember { mutableStateOf(true) }
var scrollIntent by remember { mutableStateOf(ChatScrollIntentState()) }
val density = LocalDensity.current
val scrollConnection = remember(columnState, density) {
object : NestedScrollConnection {
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
// Both Wear rotary and touch dispatch consumed movement here. Positive list
// movement is toward newer messages. Layout changes never enter this path.
scrollIntent = updatedChatScrollIntent(
current = scrollIntent,
deltaDp = -consumed.y / density.density,
isUserInput = source == NestedScrollSource.UserInput,
atNewest = !columnState.canScrollForward
)
return Offset.Zero
}
}
}
LaunchedEffect(columnState) {
var lastPosition = -1
var scrollIntent = ChatScrollIntentState()
snapshotFlow {
val first = columnState.layoutInfo.visibleItems.firstOrNull()
ChatScrollSnapshot(
canScrollForward = columnState.canScrollForward,
isScrollInProgress = columnState.isScrollInProgress,
position = (first?.index ?: 0) * 100_000 + (first?.offset ?: 0)
)
}.collect { snapshot ->
scrollIntent = updatedChatScrollIntent(
current = scrollIntent,
snapshot = snapshot,
previousPosition = lastPosition
)
followNewest = scrollIntent.followsNewest
controlsVisible.value = scrollIntent.controlsVisible
lastPosition = snapshot.position
!columnState.canScrollForward && !columnState.isScrollInProgress
}.collect { atNewest ->
if (atNewest) scrollIntent = ChatScrollIntentState()
}
}
// Stick to bottom when the user has not intentionally moved into history.
LaunchedEffect(columnState, messages.size) {
if (messages.isNotEmpty() && followNewest) {
if (messages.isNotEmpty() && scrollIntent.followsNewest) {
val expectedSingleMessageKey = messages.singleOrNull()?.id
scrollToNewestAfterItemsMeasured(
expectedItemCount = messages.size,
@ -137,7 +146,14 @@ fun ChatScaffold(
) {
// scrollBy to the end of the range: animateScrollToItem stops as soon as the
// item is partially visible, which left the last message cropped.
columnState.scroll { scrollBy(Float.MAX_VALUE) }
// Do not seize the list from an active drag/crown gesture, or follow an
// append whose measurement completed after the user entered history.
followNewestWhenIdle(
scrolling = snapshotFlow { columnState.isScrollInProgress },
shouldFollow = { scrollIntent.followsNewest }
) {
columnState.scroll { scrollBy(Float.MAX_VALUE) }
}
}
}
}
@ -149,10 +165,10 @@ fun ChatScaffold(
voice = voice,
onOpenImage = onOpenImage,
columnState = columnState,
controlsVisible = controlsVisible.value,
controlsVisible = scrollIntent.controlsVisible,
header = header,
actionBar = actionBar,
modifier = Modifier.fillMaxSize()
modifier = Modifier.fillMaxSize().nestedScroll(scrollConnection)
)
}
@ -161,47 +177,30 @@ internal data class MeasuredChatLayout(
val singleVisibleItemKey: Any?
)
internal data class ChatScrollSnapshot(
val canScrollForward: Boolean,
val isScrollInProgress: Boolean,
val position: Int
)
internal data class ChatScrollIntentState(
val followsNewest: Boolean = true,
val controlsVisible: Boolean = true,
val accumulatedDeltaPx: Int = 0
val reversalDp: Float = 0f
)
internal fun updatedChatScrollIntent(
current: ChatScrollIntentState,
snapshot: ChatScrollSnapshot,
previousPosition: Int
deltaDp: Float,
isUserInput: Boolean,
atNewest: Boolean
): ChatScrollIntentState {
if (!snapshot.canScrollForward) return ChatScrollIntentState()
if (!snapshot.isScrollInProgress || previousPosition < 0) return current
val delta = snapshot.position - previousPosition
val accumulatedDelta = when {
delta == 0 -> current.accumulatedDeltaPx
current.accumulatedDeltaPx == 0 ||
current.accumulatedDeltaPx.sign == delta.sign ->
current.accumulatedDeltaPx + delta
else -> delta
}
val movedAway = accumulatedDelta <= -CHAT_SCROLL_DIRECTION_THRESHOLD_PX
val movedTowardNewest = accumulatedDelta >= CHAT_SCROLL_DIRECTION_THRESHOLD_PX
if (atNewest) return ChatScrollIntentState()
if (!isUserInput || !deltaDp.isFinite() || deltaDp == 0f) return current
// Hysteresis measures net travel opposite the current controls state, not the sum of
// tiny back-and-forth movements. Keep it across discrete crown ticks and idle periods.
val reversal = (current.reversalDp + if (current.controlsVisible) -deltaDp else deltaDp)
.coerceAtLeast(0f)
val threshold = if (current.controlsVisible) 12f else 24f
val toggle = reversal >= threshold
return current.copy(
followsNewest = current.followsNewest && !movedAway,
controlsVisible = when {
movedAway -> false
movedTowardNewest -> true
else -> current.controlsVisible
},
// Keep sub-threshold movement across discrete rotary events. Once intent is clear,
// start a fresh accumulator so reversing direction gets the same threshold treatment.
accumulatedDeltaPx = if (movedAway || movedTowardNewest) 0 else accumulatedDelta
followsNewest = current.followsNewest && deltaDp >= 0f,
controlsVisible = if (toggle) !current.controlsVisible else current.controlsVisible,
reversalDp = if (toggle) 0f else reversal
)
}
@ -219,6 +218,15 @@ internal suspend fun scrollToNewestAfterItemsMeasured(
scrollToEnd()
}
internal suspend fun followNewestWhenIdle(
scrolling: Flow<Boolean>,
shouldFollow: () -> Boolean,
scrollToEnd: suspend () -> Unit
) {
scrolling.first { !it }
if (shouldFollow()) scrollToEnd()
}
@Composable
private fun ChatBody(
messages: List<BitchatMessage>,
@ -236,6 +244,9 @@ private fun ChatBody(
val context = LocalContext.current
val transformationSpec = rememberTransformationSpec()
val isScreenRound = LocalConfiguration.current.isScreenRound
val headerClearance = with(LocalDensity.current) {
maxOf(40.dp, 24.dp + (14f * 1.3f).sp.toDp() / 2 + 6.dp)
}
// Slide-to-cancel: while recording, the finger's position is tracked globally; the
// overlay's mic button reports its bounds and becomes the cancel target when the
// finger hovers it (with generous slack so the snap engages on approach).
@ -362,7 +373,7 @@ private fun ChatBody(
// duplicated padding and a shortened list viewport.
contentPadding = scaffoldPadding.withVerticalClearance(
layoutDirection = layoutDirection,
top = CHAT_HEADER_CONTENT_CLEARANCE,
top = headerClearance,
bottom = CHAT_ACTION_BAR_CLEARANCE
)
) {
@ -437,8 +448,6 @@ private fun ChatBody(
// Extra finger slack (px, ~28dp at watch density) around the cancel target so the snap
// engages as the finger approaches, not only on exact contact.
private const val CANCEL_HOVER_SLANT_PX = 56f
private const val CHAT_SCROLL_DIRECTION_THRESHOLD_PX = 24
private val CHAT_HEADER_CONTENT_CLEARANCE = 30.dp
private val CHAT_ACTION_BAR_CLEARANCE = 64.dp
private val CHAT_HEADER_EDGE_FADE = 36.dp
private val CHAT_ACTION_BAR_EDGE_FADE = 72.dp

View File

@ -1,9 +1,7 @@
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
import androidx.compose.foundation.layout.fillMaxSize
@ -118,77 +116,79 @@ private fun ChatHeader(
peerCount: Int,
unreadDms: Int,
expanded: Boolean,
onOpenPeople: () -> Unit
onOpenPeople: () -> Unit,
) {
// Floating title row: full-size at the newest messages, shrinks to its dense form
// while scrolling up into history. Rendered as an overlay, so the animation only
// relayouts this row, never the message list.
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"
)
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) 14.dp else 12.dp,
animationSpec = spec,
label = "hdrIcon",
)
val titleSize by
androidx.compose.animation.core.animateFloatAsState(
targetValue = if (expanded) 14f else 12f,
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
label = "hdrTitle",
)
// The entire header region opens the People screen. When there are unread DMs the
// title gives way so the people and mail icons (with counts) fit side by side on the
// round screen instead of clipping at the edges.
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenPeople() }
.padding(horizontal = 8.dp, vertical = vPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
WearChatHeader(
fontSize = titleSize,
onClickLabel = "Open people",
onClick = onOpenPeople,
) {
if (unreadDms == 0) {
Text(
text = "bitchat",
style = MaterialTheme.typography.titleSmall,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) { titleSize.toSp() },
fontSize = titleSize.sp,
lineHeight = (titleSize * 1.3f).sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 8.dp)
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false).padding(end = 2.dp),
)
}
Icon(
imageVector = Icons.Filled.People,
contentDescription = "people",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(iconSize)
modifier = Modifier.size(iconSize),
)
Text(
text = "$peerCount",
text = if (peerCount > 99) "99+" else "$peerCount",
style = MaterialTheme.typography.bodySmall,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
(iconSize.value * 0.85f).dp.toSp()
},
fontSize = 12.sp,
lineHeight = (titleSize * 1.3f).sp,
maxLines = 1,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 2.dp)
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 = 6.dp)
.size(iconSize)
modifier = Modifier.padding(start = 6.dp).size(iconSize),
)
Text(
text = "$unreadDms",
text = if (unreadDms > 99) "99+" else "$unreadDms",
style = MaterialTheme.typography.bodySmall,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
(iconSize.value * 0.85f).dp.toSp()
},
fontSize = 12.sp,
lineHeight = (titleSize * 1.3f).sp,
maxLines = 1,
color = LocalBitchatPalette.current.accentOrange,
modifier = Modifier.padding(start = 2.dp)
modifier = Modifier.padding(start = 2.dp),
)
}
}
@ -240,8 +240,7 @@ fun MessageItem(
)
Text(
text = " ${formatTime(message.timestamp)}",
style = ChatVisualTokens.SystemActionStyle,
fontSize = 9.sp,
style = ChatVisualTokens.TimestampStyle,
color = palette.textTertiary
)
}

View File

@ -1,11 +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
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -20,7 +17,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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
@ -32,6 +28,8 @@ 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
import androidx.compose.ui.unit.sp
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.lazy.items
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.Icon
@ -140,59 +138,58 @@ private fun DmHeader(
expanded: Boolean,
isFavorite: Boolean,
isVerified: Boolean,
onClick: () -> Unit
onClick: () -> Unit,
) {
val palette = LocalBitchatPalette.current
// Floating title row: full-size at the newest messages, shrinks to its dense form
// while scrolling up into history. Rendered as an overlay, so the animation only
// relayouts this row, never the message list.
val spec = androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
BitchatMotion.STANDARD_MS
)
val headerIconSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 16.dp else 11.dp, animationSpec = spec, label = "dmHdrIcon"
)
val headerTitleSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 15.dp else 11.dp, animationSpec = spec, label = "dmHdrTitle"
)
val headerVPadding by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 6.dp else 1.dp, animationSpec = spec, label = "dmHdrPad"
)
val spec =
androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
BitchatMotion.STANDARD_MS
)
val headerIconSize by
androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 14.dp else 12.dp,
animationSpec = spec,
label = "dmHdrIcon",
)
val headerTitleSize by
androidx.compose.animation.core.animateFloatAsState(
targetValue = if (expanded) 14f else 12f,
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
label = "dmHdrTitle",
)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
onClickLabel = "Open user details",
onClick = onClick
)
.padding(horizontal = 8.dp, vertical = headerVPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
WearChatHeader(
fontSize = headerTitleSize,
onClickLabel = "Open user details",
onClick = onClick,
) {
Text(
text = nickname,
style = MaterialTheme.typography.titleSmall,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
headerTitleSize.toSp()
},
fontSize = headerTitleSize.sp,
lineHeight = (headerTitleSize * 1.3f).sp,
fontWeight = FontWeight.Bold,
color = colorForPeer(nickname + peerID, palette)
color = colorForPeer(nickname + peerID, palette),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
NoiseLockIcon(
state = if (sessionEstablished) NoiseSessionUiState.Established
else NoiseSessionUiState.Handshaking,
state =
if (sessionEstablished) NoiseSessionUiState.Established
else NoiseSessionUiState.Handshaking,
size = headerIconSize,
modifier = Modifier.padding(start = 5.dp)
modifier = Modifier.padding(start = 5.dp),
)
if (isFavorite) {
Icon(
painter = painterResource(R.drawable.ic_spec_star_filled),
contentDescription = "Favorite",
tint = palette.accentOrange,
modifier = Modifier
.padding(start = 4.dp)
.size(headerIconSize)
modifier = Modifier.padding(start = 4.dp).size(headerIconSize),
)
}
if (isVerified) {
@ -200,9 +197,7 @@ private fun DmHeader(
imageVector = Icons.Filled.Verified,
contentDescription = "Verified",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(start = 4.dp)
.size(headerIconSize)
modifier = Modifier.padding(start = 4.dp).size(headerIconSize),
)
}
}

View File

@ -1,10 +1,7 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
@ -47,7 +44,7 @@ fun NicknameSetupScreen(
title: String = "bitchat",
subtitle: String = "Pick a nickname",
confirmLabel: String = "Join the mesh",
onConfirm: (String) -> Unit
onConfirm: (String) -> Unit,
) {
val palette = LocalBitchatPalette.current
// Pre-fill with the cursor at the end of the existing name, not the start.
@ -55,7 +52,7 @@ fun NicknameSetupScreen(
mutableStateOf(
TextFieldValue(
text = initialNickname,
selection = TextRange(initialNickname.length)
selection = TextRange(initialNickname.length),
)
)
}
@ -64,71 +61,78 @@ fun NicknameSetupScreen(
LaunchedEffect(Unit) { focusRequester.requestFocus() }
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 4.dp, bottom = 10.dp)
)
BasicTextField(
value = name,
onValueChange = { newValue ->
val trimmed = newValue.text.trim().take(24)
name = if (trimmed == newValue.text) {
newValue
} else {
newValue.copy(text = trimmed, selection = TextRange(trimmed.length))
}
},
singleLine = true,
textStyle = ChatVisualTokens.MessageBodyStyle.copy(
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
}),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.clip(RoundedCornerShape(18.dp))
.background(palette.inputSurface)
.padding(horizontal = 12.dp, vertical = 8.dp),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.Center) {
if (name.text.isEmpty()) {
Text(
text = "Nickname",
style = ChatVisualTokens.MessageBodyStyle,
color = palette.textTertiary
)
WearFormScreen {
item {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
item {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 4.dp, bottom = 10.dp),
)
}
item {
BasicTextField(
value = name,
onValueChange = { newValue ->
val trimmed = newValue.text.trim().take(24)
name =
if (trimmed == newValue.text) {
newValue
} else {
newValue.copy(text = trimmed, selection = TextRange(trimmed.length))
}
},
singleLine = true,
textStyle =
ChatVisualTokens.MessageBodyStyle.copy(
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(
onDone = {
keyboardController?.hide()
}
),
modifier =
Modifier.fillMaxWidth()
.focusRequester(focusRequester)
.clip(RoundedCornerShape(18.dp))
.background(palette.inputSurface)
.padding(horizontal = 12.dp, vertical = 8.dp),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.Center) {
if (name.text.isEmpty()) {
Text(
text = "Nickname",
style = ChatVisualTokens.MessageBodyStyle,
color = palette.textTertiary,
)
}
innerTextField()
}
innerTextField()
}
},
)
}
item {
Button(
onClick = { if (name.text.isNotBlank()) onConfirm(name.text.trim()) },
enabled = name.text.isNotBlank(),
modifier = Modifier.padding(top = 10.dp),
) {
Text(confirmLabel, textAlign = TextAlign.Center)
}
)
Button(
onClick = { if (name.text.isNotBlank()) onConfirm(name.text.trim()) },
enabled = name.text.isNotBlank(),
modifier = Modifier.padding(top = 10.dp)
) {
Text(confirmLabel)
}
}
}

View File

@ -134,7 +134,7 @@ fun TextInputScreen(onSend: (String) -> Unit) {
}
)
},
modifier = Modifier.size(38.dp)
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = Icons.Filled.Mic,
@ -145,7 +145,7 @@ fun TextInputScreen(onSend: (String) -> Unit) {
IconButton(
onClick = { send() },
enabled = text.isNotBlank(),
modifier = Modifier.size(38.dp)
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,

View File

@ -18,7 +18,6 @@ import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.painterResource
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.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
@ -45,7 +44,7 @@ fun UserDetailScreen(
WearPeerIdentityState.snapshot(peerID, mesh)
}
val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8)
val listState = rememberScalingLazyListState()
val listState = rememberScalingLazyListState(initialCenterItemIndex = 0)
val palette = LocalBitchatPalette.current
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
@ -53,12 +52,13 @@ fun UserDetailScreen(
ScalingLazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
autoCentering = null,
contentPadding = scaffoldPadding
.withAdditionalPadding(
layoutDirection = layoutDirection,
horizontal = 10.dp,
vertical = 8.dp
horizontal = 10.dp
)
.withVerticalClearance(layoutDirection, top = 28.dp, bottom = 28.dp)
) {
item {
ListHeader {
@ -71,8 +71,7 @@ fun UserDetailScreen(
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = colorForPeer(nickname + peerID, palette),
maxLines = 1,
overflow = TextOverflow.Ellipsis
textAlign = TextAlign.Center
)
Text(
text = "User details",
@ -174,13 +173,13 @@ fun UserDetailScreen(
text = if (identity.isVerified) {
"Identity verified"
} else {
"Verification code"
"Identity code"
},
style = ChatVisualTokens.SenderStyle,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Compare cryptographic fingerprints",
text = "Compare identity codes",
style = ChatVisualTokens.SystemActionStyle,
color = palette.textTertiary
)

View File

@ -40,7 +40,7 @@ fun VerificationCodeScreen(peerID: String) {
WearPeerIdentityState.snapshot(peerID, mesh)
}
val myFingerprint = WearPeerIdentityState.myFingerprint(mesh)
val listState = rememberScalingLazyListState()
val listState = rememberScalingLazyListState(initialCenterItemIndex = 0)
val palette = LocalBitchatPalette.current
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
@ -48,12 +48,13 @@ fun VerificationCodeScreen(peerID: String) {
ScalingLazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
autoCentering = null,
contentPadding = scaffoldPadding
.withAdditionalPadding(
layoutDirection = layoutDirection,
horizontal = 10.dp,
vertical = 8.dp
horizontal = 10.dp
)
.withVerticalClearance(layoutDirection, top = 28.dp, bottom = 28.dp)
) {
item {
ListHeader {
@ -126,7 +127,8 @@ fun VerificationCodeScreen(peerID: String) {
"Remove verification"
} else {
"Mark verified"
}
},
textAlign = TextAlign.Center
)
}
}
@ -158,8 +160,8 @@ private fun FingerprintCard(
text = fingerprint?.let(::formatVerificationCode) ?: "Handshake pending",
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
lineHeight = 13.sp
fontSize = 12.sp,
lineHeight = 16.sp
),
color = if (fingerprint == null) {
palette.accentOrange
@ -178,6 +180,6 @@ fun formatVerificationCode(fingerprint: String): String {
return fingerprint
.uppercase()
.chunked(4)
.chunked(4)
.chunked(2)
.joinToString("\n") { line -> line.joinToString(" ") }
}

View File

@ -0,0 +1,75 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.wear.compose.material3.MaterialTheme
/** The title is outside the scrolling list, so it needs its own physical display bounds. */
@Composable
internal fun WearChatHeader(
fontSize: Float,
onClickLabel: String,
onClick: () -> Unit,
content: @Composable RowScope.() -> Unit,
) {
val configuration = LocalConfiguration.current
val lineHeight = with(LocalDensity.current) { (fontSize * 1.3f).sp.toDp() }
val rowHeight = maxOf(48.dp, lineHeight)
val top = (rowHeight - lineHeight) / 2
val background = MaterialTheme.colorScheme.background
// Follow the existing title animation without animating layout or the hit target.
val expansion = ((fontSize - 12f) / 2f).coerceIn(0f, 1f)
val fadeEnd = maxOf((36f + 8f * expansion).dp, top + lineHeight + 4.dp)
BoxWithConstraints(
Modifier.fillMaxWidth().drawWithCache {
val brush =
Brush.verticalGradient(
0f to background,
0.65f to background.copy(alpha = 0.95f),
1f to Color.Transparent,
endY = fadeEnd.toPx(),
)
onDrawBehind { drawRect(brush) }
},
contentAlignment = Alignment.TopCenter,
) {
val safeWidth =
if (configuration.isScreenRound) {
roundBandWidth(
maxWidth.value,
configuration.screenHeightDp.toFloat(),
top.value,
(top + lineHeight).value,
)
.dp - 8.dp
} else {
maxWidth - 16.dp
}
Row(
modifier =
Modifier.width(safeWidth.coerceAtLeast(48.dp))
.height(rowHeight)
.clickable(role = Role.Button, onClickLabel = onClickLabel, onClick = onClick),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
content = content,
)
}
}

View File

@ -0,0 +1,16 @@
package com.bitchat.watch.ui
import kotlin.math.abs
import kotlin.math.min
import kotlin.math.sqrt
/** Width of the narrowest chord across a centered horizontal band in a round display. */
internal fun roundBandWidth(width: Float, height: Float, top: Float, bottom: Float): Float {
val radius = min(width, height) / 2f
val distance = maxOf(abs(top - height / 2f), abs(bottom - height / 2f))
return 2f * sqrt((radius * radius - distance * distance).coerceAtLeast(0f))
}
/** A centered square whose four corners fit inside the physical circle, with a small inset. */
internal fun roundContentSide(width: Float, height: Float): Float =
(min(width, height) / sqrt(2f) - 4f).coerceAtLeast(0f)

View File

@ -0,0 +1,30 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.lazy.ScalingLazyListScope
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
import androidx.wear.compose.material3.ScreenScaffold
/** Independently scrollable form items remain reachable on small watches and at large fonts. */
@Composable
internal fun WearFormScreen(content: ScalingLazyListScope.() -> Unit) {
val state = rememberScalingLazyListState(initialCenterItemIndex = 0)
val direction = LocalLayoutDirection.current
ScreenScaffold(scrollState = state) { padding ->
ScalingLazyColumn(
state = state,
modifier = Modifier.fillMaxSize(),
autoCentering = null,
contentPadding =
padding
.withAdditionalPadding(direction, horizontal = 14.dp)
.withVerticalClearance(direction, top = 28.dp, bottom = 28.dp),
content = content,
)
}
}

View File

@ -8,6 +8,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
@ -43,6 +44,7 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@ -53,6 +55,7 @@ import androidx.wear.compose.material3.Text
import com.bitchat.android.features.voice.AudioWaveformExtractor
import com.bitchat.android.features.voice.VoiceWaveformCache
import com.bitchat.watch.ui.theme.ChatVisualTokens
import com.bitchat.watch.ui.roundContentSide
import com.bitchat.watch.ui.theme.LocalBitchatPalette
import kotlinx.coroutines.delay
import java.io.File
@ -92,7 +95,7 @@ fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Box(
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
@ -101,11 +104,16 @@ fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
) {
val bitmap = remember(path) { BitmapFactory.decodeFile(path) }
if (bitmap != null) {
val imageModifier = if (LocalConfiguration.current.isScreenRound) {
Modifier.size(roundContentSide(maxWidth.value, maxHeight.value).dp)
} else {
Modifier.fillMaxSize()
}
Image(
painter = BitmapPainter(bitmap.asImageBitmap()),
contentDescription = "image fullscreen",
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize()
modifier = imageModifier
)
}
Icon(
@ -114,7 +122,7 @@ fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 24.dp)
.padding(top = 8.dp)
.size(20.dp)
)
}

View File

@ -37,7 +37,9 @@ object ChatVisualTokens {
val SystemActionStyle = TextStyle(
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 14.sp,
fontSize = 12.sp,
lineHeight = 15.sp,
)
val TimestampStyle = SystemActionStyle.copy(fontSize = 10.sp, lineHeight = 12.sp)
}

View File

@ -11,99 +11,131 @@ import org.junit.Test
class ChatAutoScrollTest {
@Test
fun `new scroll range from appended message keeps follow intent`() {
val updated = updatedChatScrollIntent(
current = ChatScrollIntentState(),
snapshot = ChatScrollSnapshot(
canScrollForward = true,
isScrollInProgress = false,
position = 100
),
previousPosition = 100
)
assertEquals(ChatScrollIntentState(), updated)
fun `append waits for active gesture and rechecks history intent`() = runTest {
val scrolling = MutableStateFlow(true)
var followsNewest = true
var scrollCount = 0
val job =
launch(start = CoroutineStart.UNDISPATCHED) {
followNewestWhenIdle(scrolling, { followsNewest }) { scrollCount++ }
}
assertFalse(job.isCompleted)
assertEquals(0, scrollCount)
followsNewest = false
scrolling.value = false
job.join()
assertEquals(0, scrollCount)
}
@Test
fun `user scroll away disables follow until list reaches newest again`() {
val browsingHistory = updatedChatScrollIntent(
current = ChatScrollIntentState(),
snapshot = ChatScrollSnapshot(
canScrollForward = true,
isScrollInProgress = true,
position = 60
),
previousPosition = 100
)
assertFalse(browsingHistory.followsNewest)
assertFalse(browsingHistory.controlsVisible)
fun `append follows after gesture settles when user remains at newest`() = runTest {
val scrolling = MutableStateFlow(true)
var scrollCount = 0
val job =
launch(start = CoroutineStart.UNDISPATCHED) {
followNewestWhenIdle(scrolling, { true }) { scrollCount++ }
}
assertEquals(0, scrollCount)
scrolling.value = false
job.join()
assertEquals(1, scrollCount)
}
val dockedAgain = updatedChatScrollIntent(
current = browsingHistory,
snapshot = ChatScrollSnapshot(
canScrollForward = false,
isScrollInProgress = false,
position = 200
),
previousPosition = 60
)
assertEquals(ChatScrollIntentState(), dockedAgain)
private fun move(
state: ChatScrollIntentState,
dp: Float,
user: Boolean = true,
newest: Boolean = false,
) = updatedChatScrollIntent(state, dp, user, newest)
@Test
fun `append and programmatic movement never change intent`() {
val docked = ChatScrollIntentState()
assertEquals(docked, move(docked, 1000f, user = false))
val history = ChatScrollIntentState(false, false)
assertEquals(history, move(history, 1000f, user = false))
}
@Test
fun `slow scroll away accumulates intent across sub-threshold updates`() {
var state = ChatScrollIntentState()
var previousPosition = 100
listOf(94, 88, 82, 76).forEach { position ->
state = updatedChatScrollIntent(
current = state,
snapshot = ChatScrollSnapshot(
canScrollForward = true,
isScrollInProgress = true,
position = position
),
previousPosition = previousPosition
)
previousPosition = position
state = updatedChatScrollIntent(
current = state,
snapshot = ChatScrollSnapshot(
canScrollForward = true,
isScrollInProgress = false,
position = position
),
previousPosition = previousPosition
)
}
assertFalse(state.followsNewest)
assertFalse(state.controlsVisible)
assertEquals(0, state.accumulatedDeltaPx)
}
@Test
fun `slow scroll toward newest reveals controls without restoring follow early`() {
var state = ChatScrollIntentState(followsNewest = false, controlsVisible = false)
var previousPosition = 60
listOf(66, 72, 78, 84).forEach { position ->
state = updatedChatScrollIntent(
current = state,
snapshot = ChatScrollSnapshot(
canScrollForward = true,
isScrollInProgress = true,
position = position
),
previousPosition = previousPosition
)
previousPosition = position
}
fun `first consumed movement away suspends following before controls hide`() {
val state = move(ChatScrollIntentState(), -1f)
assertFalse(state.followsNewest)
assertEquals(true, state.controlsVisible)
assertEquals(0, state.accumulatedDeltaPx)
assertFalse(move(state, -11f).controlsVisible)
}
@Test
fun `deliberate reversal reveals controls but does not resume following`() {
val history = move(ChatScrollIntentState(), -12f)
val almost = move(history, 23f)
assertFalse(almost.controlsVisible)
val revealed = move(almost, 1f)
assertEquals(true, revealed.controlsVisible)
assertFalse(revealed.followsNewest)
assertEquals(ChatScrollIntentState(), move(revealed, 1f, newest = true))
}
@Test
fun `jitter does not accumulate into repeated toggles`() {
var state = move(ChatScrollIntentState(), -12f)
repeat(100) {
state = move(state, 3f)
state = move(state, -3f)
}
assertFalse(state.controlsVisible)
assertEquals(0f, state.reversalDp)
}
@Test
fun `pauses and discrete crown ticks retain net movement`() {
var state = ChatScrollIntentState()
repeat(4) {
state = move(state, -3f)
state = move(state, 0f, user = false)
}
assertFalse(state.controlsVisible)
repeat(8) {
state = move(state, 3f)
state = move(state, 0f, user = false)
}
assertEquals(true, state.controlsVisible)
assertFalse(state.followsNewest)
}
@Test
fun `fling preserves controls until newest is reached`() {
val history = move(ChatScrollIntentState(), -12f)
assertEquals(history, move(history, 1000f, user = false))
assertEquals(history, move(history, -1000f, user = false))
assertEquals(ChatScrollIntentState(), move(history, 1f, user = false, newest = true))
}
@Test
fun `consumed distance is independent of item boundaries and event chunking`() {
val initial = ChatScrollIntentState()
val oneEvent = move(initial, -12f)
val acrossRows =
listOf(-2f, -3f, -1f, -6f).fold(initial) { state, delta ->
move(state, delta)
}
assertEquals(oneEvent, acrossRows)
}
@Test
fun `pixel distances normalize to the same dp thresholds`() {
for (density in listOf(1f, 1.6875f, 2f, 3f)) {
val history = move(ChatScrollIntentState(), (-12f * density) / density)
assertFalse(history.controlsVisible)
assertEquals(true, move(history, (24f * density) / density).controlsVisible)
}
}
@Test
fun `invalid and unconsumed input is ignored`() {
val initial = ChatScrollIntentState()
assertEquals(initial, move(initial, Float.NaN))
assertEquals(initial, move(initial, Float.POSITIVE_INFINITY))
assertEquals(initial, move(initial, 0f))
}
@Test
@ -111,15 +143,16 @@ class ChatAutoScrollTest {
val measuredLayouts = MutableStateFlow(MeasuredChatLayout(3, null))
var scrollCount = 0
val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 4,
expectedSingleMessageKey = null,
measuredLayouts = measuredLayouts
) {
scrollCount += 1
val scrollJob =
launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 4,
expectedSingleMessageKey = null,
measuredLayouts = measuredLayouts,
) {
scrollCount += 1
}
}
}
assertFalse(scrollJob.isCompleted)
assertEquals(0, scrollCount)
@ -138,7 +171,7 @@ class ChatAutoScrollTest {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 4,
expectedSingleMessageKey = null,
measuredLayouts = measuredLayouts
measuredLayouts = measuredLayouts,
) {
scrollCount += 1
}
@ -149,28 +182,31 @@ class ChatAutoScrollTest {
@Test
fun `first message waits past stale empty placeholder layout`() = runTest {
val messageKey = "first-message"
val measuredLayouts = MutableStateFlow(
MeasuredChatLayout(itemCount = 1, singleVisibleItemKey = "empty-placeholder")
)
val measuredLayouts =
MutableStateFlow(
MeasuredChatLayout(itemCount = 1, singleVisibleItemKey = "empty-placeholder")
)
var scrollCount = 0
val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 1,
expectedSingleMessageKey = messageKey,
measuredLayouts = measuredLayouts
) {
scrollCount += 1
val scrollJob =
launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 1,
expectedSingleMessageKey = messageKey,
measuredLayouts = measuredLayouts,
) {
scrollCount += 1
}
}
}
assertFalse(scrollJob.isCompleted)
assertEquals(0, scrollCount)
measuredLayouts.value = MeasuredChatLayout(
itemCount = 1,
singleVisibleItemKey = messageKey
)
measuredLayouts.value =
MeasuredChatLayout(
itemCount = 1,
singleVisibleItemKey = messageKey,
)
scrollJob.join()
assertEquals(1, scrollCount)

View File

@ -0,0 +1,51 @@
package com.bitchat.watch.ui
import com.bitchat.watch.ui.theme.ChatVisualTokens
import kotlin.math.pow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class WearDisplayGeometryTest {
@Test
fun `header band corners stay inside the circle at supported text sizes`() {
for (diameter in listOf(192f, 228f, 240f)) {
for (scale in listOf(0.94f, 1f, 1.24f, 1.3f)) {
for (titleSize in listOf(12f, 13f, 14f)) {
val lineHeight = titleSize * 1.3f * scale
val top = (maxOf(48f, lineHeight) - lineHeight) / 2f
val width = roundBandWidth(diameter, diameter, top, top + lineHeight)
val radius = diameter / 2f
for (y in listOf(top, top + lineHeight)) {
assertTrue(
(width / 2).pow(2) + (y - radius).pow(2) <= radius.pow(2) + 0.01f
)
}
}
}
}
}
@Test
fun `out of display bands have no usable width`() {
assertEquals(0f, roundBandWidth(192f, 192f, -1f, 20f))
assertEquals(0f, roundBandWidth(192f, 192f, 180f, 193f))
}
@Test
fun `image and recording safe square fits all four corners`() {
for (diameter in listOf(192f, 228f, 240f)) {
val side = roundContentSide(diameter, diameter)
assertTrue(side > 0)
assertTrue(2 * (side / 2).pow(2) < (diameter / 2).pow(2))
}
}
@Test
fun `essential shared text is at least twelve sp`() {
assertTrue(ChatVisualTokens.SystemActionStyle.fontSize.value >= 12f)
assertTrue(ChatVisualTokens.MessageBodyStyle.fontSize.value >= 12f)
assertTrue(ChatVisualTokens.SenderStyle.fontSize.value >= 12f)
assertTrue(ChatVisualTokens.TimestampStyle.fontSize.value >= 10f)
}
}

View File

@ -32,7 +32,7 @@ class WearPeerIdentityStateTest {
val formatted = formatVerificationCode(fingerprint)
assertEquals(fingerprint.uppercase(), formatted.filterNot(Char::isWhitespace))
assertEquals(4, formatted.lines().size)
assertEquals(listOf(4, 4, 4, 4), formatted.lines().map { it.split(" ").size })
assertEquals(8, formatted.lines().size)
assertEquals(List(8) { 2 }, formatted.lines().map { it.split(" ").size })
}
}