From afad3c551350141381ea7999c6fbbe730a95b28f Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:42:43 +0300 Subject: [PATCH 1/2] Fix Wear layouts on small round displays --- .../java/com/bitchat/watch/MainActivity.kt | 179 +++++++-------- .../com/bitchat/watch/ui/ChatActionBar.kt | 210 ++++++++++-------- .../java/com/bitchat/watch/ui/ChatScaffold.kt | 2 +- .../java/com/bitchat/watch/ui/ChatScreen.kt | 79 ++++--- .../java/com/bitchat/watch/ui/DmScreen.kt | 75 +++---- .../bitchat/watch/ui/NicknameSetupScreen.kt | 140 ++++++------ .../com/bitchat/watch/ui/TextInputScreen.kt | 4 +- .../com/bitchat/watch/ui/UserDetailScreen.kt | 15 +- .../watch/ui/VerificationCodeScreen.kt | 16 +- .../com/bitchat/watch/ui/WearChatHeader.kt | 71 ++++++ .../bitchat/watch/ui/WearDisplayGeometry.kt | 16 ++ .../com/bitchat/watch/ui/WearFormScreen.kt | 30 +++ .../com/bitchat/watch/ui/media/MediaItems.kt | 14 +- .../com/bitchat/watch/ui/theme/Typography.kt | 6 +- .../watch/ui/WearDisplayGeometryTest.kt | 47 ++++ .../watch/ui/WearPeerIdentityStateTest.kt | 4 +- 16 files changed, 556 insertions(+), 352 deletions(-) create mode 100644 wear/src/main/java/com/bitchat/watch/ui/WearChatHeader.kt create mode 100644 wear/src/main/java/com/bitchat/watch/ui/WearDisplayGeometry.kt create mode 100644 wear/src/main/java/com/bitchat/watch/ui/WearFormScreen.kt create mode 100644 wear/src/test/java/com/bitchat/watch/ui/WearDisplayGeometryTest.kt diff --git a/wear/src/main/java/com/bitchat/watch/MainActivity.kt b/wear/src/main/java/com/bitchat/watch/MainActivity.kt index fd03e9f3..dc07a949 100644 --- a/wear/src/main/java/com/bitchat/watch/MainActivity.kt +++ b/wear/src/main/java/com/bitchat/watch/MainActivity.kt @@ -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") + } } } } diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt index 72bdc8af..3f6deead 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt @@ -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) - ) } } } diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt index f304446e..20880cab 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -438,7 +438,7 @@ private fun ChatBody( // 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_HEADER_CONTENT_CLEARANCE = 56.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 diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt index d91f864b..d3f275e3 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt @@ -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( - 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( + 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.animateFloatAsState( + targetValue = if (expanded) 15f 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 = 8.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 ) } diff --git a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt index 1d179a0b..eac4a5c9 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt @@ -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( - 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( + 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.animateFloatAsState( + targetValue = if (expanded) 15f 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), ) } } diff --git a/wear/src/main/java/com/bitchat/watch/ui/NicknameSetupScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/NicknameSetupScreen.kt index 68710025..b49e634d 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/NicknameSetupScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/NicknameSetupScreen.kt @@ -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) } } } diff --git a/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt index f73891f9..df1b92a2 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt @@ -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, diff --git a/wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt index 75f2db9c..fd6a347f 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt @@ -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 ) diff --git a/wear/src/main/java/com/bitchat/watch/ui/VerificationCodeScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/VerificationCodeScreen.kt index 104ab660..4644e9bd 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/VerificationCodeScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/VerificationCodeScreen.kt @@ -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(" ") } } diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearChatHeader.kt b/wear/src/main/java/com/bitchat/watch/ui/WearChatHeader.kt new file mode 100644 index 00000000..b30ab885 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearChatHeader.kt @@ -0,0 +1,71 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.background +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.padding +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.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 = 12.dp + (rowHeight - lineHeight) / 2 + BoxWithConstraints( + Modifier.fillMaxWidth() + .background( + Brush.verticalGradient( + 0f to MaterialTheme.colorScheme.background, + 0.75f to MaterialTheme.colorScheme.background, + 1f to Color.Transparent, + ) + ), + 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.padding(top = 12.dp) + .width(safeWidth.coerceAtLeast(0.dp)) + .height(rowHeight) + .clickable(role = Role.Button, onClickLabel = onClickLabel, onClick = onClick), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + content = content, + ) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearDisplayGeometry.kt b/wear/src/main/java/com/bitchat/watch/ui/WearDisplayGeometry.kt new file mode 100644 index 00000000..894e43a6 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearDisplayGeometry.kt @@ -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) diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearFormScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/WearFormScreen.kt new file mode 100644 index 00000000..75cf3703 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearFormScreen.kt @@ -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, + ) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt b/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt index e27f132e..707132de 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt @@ -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) ) } diff --git a/wear/src/main/java/com/bitchat/watch/ui/theme/Typography.kt b/wear/src/main/java/com/bitchat/watch/ui/theme/Typography.kt index ff66567d..aca6c631 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/theme/Typography.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/theme/Typography.kt @@ -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) } diff --git a/wear/src/test/java/com/bitchat/watch/ui/WearDisplayGeometryTest.kt b/wear/src/test/java/com/bitchat/watch/ui/WearDisplayGeometryTest.kt new file mode 100644 index 00000000..96bc4e99 --- /dev/null +++ b/wear/src/test/java/com/bitchat/watch/ui/WearDisplayGeometryTest.kt @@ -0,0 +1,47 @@ +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)) { + val lineHeight = 15f * 1.3f * scale + val top = 12f + (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) + } +} diff --git a/wear/src/test/java/com/bitchat/watch/ui/WearPeerIdentityStateTest.kt b/wear/src/test/java/com/bitchat/watch/ui/WearPeerIdentityStateTest.kt index 1b241690..9e26b1ef 100644 --- a/wear/src/test/java/com/bitchat/watch/ui/WearPeerIdentityStateTest.kt +++ b/wear/src/test/java/com/bitchat/watch/ui/WearPeerIdentityStateTest.kt @@ -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 }) } } From 08974107dc67de4ce6528bb7305d412d52a1c726 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:20:35 +0300 Subject: [PATCH 2/2] Compact Wear chat headers and stabilize scroll controls --- .../java/com/bitchat/watch/ui/ChatScaffold.kt | 129 ++++----- .../java/com/bitchat/watch/ui/ChatScreen.kt | 6 +- .../java/com/bitchat/watch/ui/DmScreen.kt | 4 +- .../com/bitchat/watch/ui/WearChatHeader.kt | 24 +- .../bitchat/watch/ui/ChatAutoScrollTest.kt | 252 ++++++++++-------- .../watch/ui/WearDisplayGeometryTest.kt | 16 +- 6 files changed, 242 insertions(+), 189 deletions(-) diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt index 20880cab..ffd08ead 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -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, + shouldFollow: () -> Boolean, + scrollToEnd: suspend () -> Unit +) { + scrolling.first { !it } + if (shouldFollow()) scrollToEnd() +} + @Composable private fun ChatBody( messages: List, @@ -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 = 56.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 diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt index d3f275e3..d576cd03 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt @@ -127,13 +127,13 @@ private fun ChatHeader( ) val iconSize by androidx.compose.animation.core.animateDpAsState( - targetValue = if (expanded) 16.dp else 11.dp, + targetValue = if (expanded) 14.dp else 12.dp, animationSpec = spec, label = "hdrIcon", ) val titleSize by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (expanded) 15f else 12f, + targetValue = if (expanded) 14f else 12f, animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS), label = "hdrTitle", ) @@ -156,7 +156,7 @@ private fun ChatHeader( color = MaterialTheme.colorScheme.primary, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false).padding(end = 8.dp), + modifier = Modifier.weight(1f, fill = false).padding(end = 2.dp), ) } Icon( diff --git a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt index eac4a5c9..d0e675ea 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt @@ -150,13 +150,13 @@ private fun DmHeader( ) val headerIconSize by androidx.compose.animation.core.animateDpAsState( - targetValue = if (expanded) 16.dp else 11.dp, + targetValue = if (expanded) 14.dp else 12.dp, animationSpec = spec, label = "dmHdrIcon", ) val headerTitleSize by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (expanded) 15f else 12f, + targetValue = if (expanded) 14f else 12f, animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS), label = "dmHdrTitle", ) diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearChatHeader.kt b/wear/src/main/java/com/bitchat/watch/ui/WearChatHeader.kt index b30ab885..aeb19766 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/WearChatHeader.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/WearChatHeader.kt @@ -1,6 +1,5 @@ package com.bitchat.watch.ui -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints @@ -8,11 +7,11 @@ 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.padding 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 @@ -33,16 +32,22 @@ internal fun WearChatHeader( val configuration = LocalConfiguration.current val lineHeight = with(LocalDensity.current) { (fontSize * 1.3f).sp.toDp() } val rowHeight = maxOf(48.dp, lineHeight) - val top = 12.dp + (rowHeight - lineHeight) / 2 + 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() - .background( + Modifier.fillMaxWidth().drawWithCache { + val brush = Brush.verticalGradient( - 0f to MaterialTheme.colorScheme.background, - 0.75f to MaterialTheme.colorScheme.background, + 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 = @@ -59,8 +64,7 @@ internal fun WearChatHeader( } Row( modifier = - Modifier.padding(top = 12.dp) - .width(safeWidth.coerceAtLeast(0.dp)) + Modifier.width(safeWidth.coerceAtLeast(48.dp)) .height(rowHeight) .clickable(role = Role.Button, onClickLabel = onClickLabel, onClick = onClick), horizontalArrangement = Arrangement.Center, diff --git a/wear/src/test/java/com/bitchat/watch/ui/ChatAutoScrollTest.kt b/wear/src/test/java/com/bitchat/watch/ui/ChatAutoScrollTest.kt index 7b2d13b9..79242d85 100644 --- a/wear/src/test/java/com/bitchat/watch/ui/ChatAutoScrollTest.kt +++ b/wear/src/test/java/com/bitchat/watch/ui/ChatAutoScrollTest.kt @@ -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) diff --git a/wear/src/test/java/com/bitchat/watch/ui/WearDisplayGeometryTest.kt b/wear/src/test/java/com/bitchat/watch/ui/WearDisplayGeometryTest.kt index 96bc4e99..c274657c 100644 --- a/wear/src/test/java/com/bitchat/watch/ui/WearDisplayGeometryTest.kt +++ b/wear/src/test/java/com/bitchat/watch/ui/WearDisplayGeometryTest.kt @@ -11,12 +11,16 @@ class WearDisplayGeometryTest { 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)) { - val lineHeight = 15f * 1.3f * scale - val top = 12f + (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) + 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 + ) + } } } }