From 5852fca9b5a12837ec2ac8a4b4b5a1f5e31cac7c Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:38:26 +0200 Subject: [PATCH 01/13] Fix Wear content clipping on watch shapes --- wear/build.gradle.kts | 4 +- .../java/com/bitchat/watch/ui/ChatScaffold.kt | 11 ++-- .../com/bitchat/watch/ui/PeerDebugScreen.kt | 5 +- .../java/com/bitchat/watch/ui/PeopleScreen.kt | 5 +- .../com/bitchat/watch/ui/UserDetailScreen.kt | 11 ++-- .../watch/ui/VerificationCodeScreen.kt | 11 ++-- .../bitchat/watch/ui/WearContentPadding.kt | 52 +++++++++++++++++++ .../watch/ui/WearContentPaddingTest.kt | 40 ++++++++++++++ 8 files changed, 124 insertions(+), 15 deletions(-) create mode 100644 wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt create mode 100644 wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index e177c3e2..9bd87331 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -19,8 +19,8 @@ android { targetSdk = libs.versions.targetSdk.get().toInt() // Wear releases use a separate high range because Play requires every artifact in // one application ID to have a unique version code across all form factors. - versionCode = 1_000_000_002 - versionName = "0.1.1" + versionCode = 1_000_000_003 + versionName = "0.1.2" vectorDrawables { useSupportLibrary = true 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 0bcdfed3..9f7b54f4 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -8,7 +8,6 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -28,6 +27,7 @@ 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.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.wear.compose.foundation.lazy.TransformingLazyColumn @@ -256,7 +256,8 @@ private fun ChatBody( } } ) { - ScreenScaffold(scrollState = columnState) { + ScreenScaffold(scrollState = columnState) { scaffoldPadding -> + val layoutDirection = LocalLayoutDirection.current TransformingLazyColumn( state = columnState, modifier = Modifier.fillMaxSize(), @@ -265,7 +266,11 @@ private fun ChatBody( // The padding reserves permanent room for the floating header and action // bar; being constant, it never disturbs an in-flight scroll gesture. verticalArrangement = Arrangement.Bottom, - contentPadding = PaddingValues(top = 30.dp, bottom = 64.dp) + contentPadding = scaffoldPadding.withMinimumVerticalPadding( + layoutDirection = layoutDirection, + top = 30.dp, + bottom = 64.dp + ) ) { if (messages.isEmpty()) { item { diff --git a/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt index 1994f976..c91a220e 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt @@ -46,10 +46,11 @@ fun PeerDebugScreen() { val rssi = mesh?.getPeerRSSI() ?: emptyMap() val identityRevision by WearPeerIdentityState.revision.collectAsState() - ScreenScaffold(scrollState = listState) { + ScreenScaffold(scrollState = listState) { contentPadding -> ScalingLazyColumn( state = listState, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding ) { item { ListHeader { diff --git a/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt index 3cb86df3..b3f912f9 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt @@ -67,10 +67,11 @@ fun PeopleScreen(onOpenDm: (String) -> Unit, onEditNickname: () -> Unit) { ) } - ScreenScaffold(scrollState = listState) { + ScreenScaffold(scrollState = listState) { contentPadding -> ScalingLazyColumn( state = listState, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding ) { item { ListHeader { 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 a36c7ec6..1c2a5616 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt @@ -1,7 +1,6 @@ package com.bitchat.watch.ui import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -15,6 +14,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +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 @@ -48,11 +48,16 @@ fun UserDetailScreen( val listState = rememberScalingLazyListState() val palette = LocalBitchatPalette.current - ScreenScaffold(scrollState = listState) { + ScreenScaffold(scrollState = listState) { scaffoldPadding -> + val layoutDirection = LocalLayoutDirection.current ScalingLazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp) + contentPadding = scaffoldPadding.withAdditionalPadding( + layoutDirection = layoutDirection, + horizontal = 10.dp, + vertical = 8.dp + ) ) { item { ListHeader { 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 c4c4ef0f..67517c91 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/VerificationCodeScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/VerificationCodeScreen.kt @@ -2,7 +2,6 @@ package com.bitchat.watch.ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -14,6 +13,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -43,11 +43,16 @@ fun VerificationCodeScreen(peerID: String) { val listState = rememberScalingLazyListState() val palette = LocalBitchatPalette.current - ScreenScaffold(scrollState = listState) { + ScreenScaffold(scrollState = listState) { scaffoldPadding -> + val layoutDirection = LocalLayoutDirection.current ScalingLazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp) + contentPadding = scaffoldPadding.withAdditionalPadding( + layoutDirection = layoutDirection, + horizontal = 10.dp, + vertical = 8.dp + ) ) { item { ListHeader { diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt b/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt new file mode 100644 index 00000000..28425369 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt @@ -0,0 +1,52 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp + +/** + * Preserve the responsive, shape-aware padding supplied by Wear Material while allowing a + * screen to reserve additional room for its own content or floating controls. + */ +internal fun PaddingValues.withAdditionalPadding( + layoutDirection: LayoutDirection, + horizontal: Dp = 0.dp, + vertical: Dp = 0.dp +): PaddingValues { + val start = when (layoutDirection) { + LayoutDirection.Ltr -> calculateLeftPadding(layoutDirection) + LayoutDirection.Rtl -> calculateRightPadding(layoutDirection) + } + val end = when (layoutDirection) { + LayoutDirection.Ltr -> calculateRightPadding(layoutDirection) + LayoutDirection.Rtl -> calculateLeftPadding(layoutDirection) + } + return PaddingValues( + start = start + horizontal, + top = calculateTopPadding() + vertical, + end = end + horizontal, + bottom = calculateBottomPadding() + vertical + ) +} + +internal fun PaddingValues.withMinimumVerticalPadding( + layoutDirection: LayoutDirection, + top: Dp, + bottom: Dp +): PaddingValues { + val start = when (layoutDirection) { + LayoutDirection.Ltr -> calculateLeftPadding(layoutDirection) + LayoutDirection.Rtl -> calculateRightPadding(layoutDirection) + } + val end = when (layoutDirection) { + LayoutDirection.Ltr -> calculateRightPadding(layoutDirection) + LayoutDirection.Rtl -> calculateLeftPadding(layoutDirection) + } + return PaddingValues( + start = start, + top = maxOf(calculateTopPadding(), top), + end = end, + bottom = maxOf(calculateBottomPadding(), bottom) + ) +} diff --git a/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt b/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt new file mode 100644 index 00000000..2b16c876 --- /dev/null +++ b/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt @@ -0,0 +1,40 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Test + +class WearContentPaddingTest { + + @Test + fun `additional padding preserves scaffold insets`() { + val resolved = PaddingValues(start = 10.dp, top = 18.dp, end = 12.dp, bottom = 20.dp) + .withAdditionalPadding( + layoutDirection = LayoutDirection.Ltr, + horizontal = 10.dp, + vertical = 8.dp + ) + + assertEquals(20.dp, resolved.calculateLeftPadding(LayoutDirection.Ltr)) + assertEquals(22.dp, resolved.calculateRightPadding(LayoutDirection.Ltr)) + assertEquals(26.dp, resolved.calculateTopPadding()) + assertEquals(28.dp, resolved.calculateBottomPadding()) + } + + @Test + fun `chat padding keeps responsive sides and overlay clearances`() { + val resolved = PaddingValues(start = 10.dp, top = 18.dp, end = 12.dp, bottom = 20.dp) + .withMinimumVerticalPadding( + layoutDirection = LayoutDirection.Ltr, + top = 30.dp, + bottom = 64.dp + ) + + assertEquals(10.dp, resolved.calculateLeftPadding(LayoutDirection.Ltr)) + assertEquals(12.dp, resolved.calculateRightPadding(LayoutDirection.Ltr)) + assertEquals(30.dp, resolved.calculateTopPadding()) + assertEquals(64.dp, resolved.calculateBottomPadding()) + } +} From 2e41dabd07398ce614a25f7aa959b5b94616ca3b Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:54:20 +0200 Subject: [PATCH 02/13] Add round-screen responsive Wear insets --- .../java/com/bitchat/watch/ui/ChatScaffold.kt | 12 +++++---- .../com/bitchat/watch/ui/PeerDebugScreen.kt | 6 +++-- .../java/com/bitchat/watch/ui/PeopleScreen.kt | 6 +++-- .../com/bitchat/watch/ui/UserDetailScreen.kt | 12 +++++---- .../watch/ui/VerificationCodeScreen.kt | 12 +++++---- .../bitchat/watch/ui/WearContentPadding.kt | 25 +++++++++++++++++++ .../watch/ui/WearContentPaddingTest.kt | 7 ++++++ 7 files changed, 61 insertions(+), 19 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 9f7b54f4..7bbbcd77 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -266,11 +266,13 @@ private fun ChatBody( // The padding reserves permanent room for the floating header and action // bar; being constant, it never disturbs an in-flight scroll gesture. verticalArrangement = Arrangement.Bottom, - contentPadding = scaffoldPadding.withMinimumVerticalPadding( - layoutDirection = layoutDirection, - top = 30.dp, - bottom = 64.dp - ) + contentPadding = scaffoldPadding + .withRoundScreenPadding(layoutDirection) + .withMinimumVerticalPadding( + layoutDirection = layoutDirection, + top = 30.dp, + bottom = 64.dp + ) ) { if (messages.isEmpty()) { item { diff --git a/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt index c91a220e..bda2c2bd 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +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 @@ -46,11 +47,12 @@ fun PeerDebugScreen() { val rssi = mesh?.getPeerRSSI() ?: emptyMap() val identityRevision by WearPeerIdentityState.revision.collectAsState() - ScreenScaffold(scrollState = listState) { contentPadding -> + ScreenScaffold(scrollState = listState) { scaffoldPadding -> + val layoutDirection = LocalLayoutDirection.current ScalingLazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding + contentPadding = scaffoldPadding.withRoundScreenPadding(layoutDirection) ) { item { ListHeader { diff --git a/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt index b3f912f9..d8d7df17 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -67,11 +68,12 @@ fun PeopleScreen(onOpenDm: (String) -> Unit, onEditNickname: () -> Unit) { ) } - ScreenScaffold(scrollState = listState) { contentPadding -> + ScreenScaffold(scrollState = listState) { scaffoldPadding -> + val layoutDirection = LocalLayoutDirection.current ScalingLazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding + contentPadding = scaffoldPadding.withRoundScreenPadding(layoutDirection) ) { item { ListHeader { 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 1c2a5616..1188fed0 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt @@ -53,11 +53,13 @@ fun UserDetailScreen( ScalingLazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = scaffoldPadding.withAdditionalPadding( - layoutDirection = layoutDirection, - horizontal = 10.dp, - vertical = 8.dp - ) + contentPadding = scaffoldPadding + .withRoundScreenPadding(layoutDirection) + .withAdditionalPadding( + layoutDirection = layoutDirection, + horizontal = 10.dp, + vertical = 8.dp + ) ) { item { ListHeader { 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 67517c91..89e83ee3 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/VerificationCodeScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/VerificationCodeScreen.kt @@ -48,11 +48,13 @@ fun VerificationCodeScreen(peerID: String) { ScalingLazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = scaffoldPadding.withAdditionalPadding( - layoutDirection = layoutDirection, - horizontal = 10.dp, - vertical = 8.dp - ) + contentPadding = scaffoldPadding + .withRoundScreenPadding(layoutDirection) + .withAdditionalPadding( + layoutDirection = layoutDirection, + horizontal = 10.dp, + vertical = 8.dp + ) ) { item { ListHeader { diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt b/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt index 28425369..0419bdfa 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt @@ -1,9 +1,12 @@ package com.bitchat.watch.ui import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import kotlin.math.ceil /** * Preserve the responsive, shape-aware padding supplied by Wear Material while allowing a @@ -50,3 +53,25 @@ internal fun PaddingValues.withMinimumVerticalPadding( bottom = maxOf(calculateBottomPadding(), bottom) ) } + +@Composable +internal fun PaddingValues.withRoundScreenPadding( + layoutDirection: LayoutDirection +): PaddingValues = withAdditionalPadding( + layoutDirection = layoutDirection, + horizontal = additionalRoundScreenPadding( + screenWidthDp = LocalConfiguration.current.screenWidthDp, + isScreenRound = LocalConfiguration.current.isScreenRound + ) +) + +internal fun additionalRoundScreenPadding( + screenWidthDp: Int, + isScreenRound: Boolean +): Dp = if (isScreenRound) { + ceil(screenWidthDp * ROUND_SCREEN_ADDITIONAL_PADDING_FRACTION).toInt().dp +} else { + 0.dp +} + +private const val ROUND_SCREEN_ADDITIONAL_PADDING_FRACTION = 0.10f diff --git a/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt b/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt index 2b16c876..3fe5273b 100644 --- a/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt +++ b/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt @@ -37,4 +37,11 @@ class WearContentPaddingTest { assertEquals(30.dp, resolved.calculateTopPadding()) assertEquals(64.dp, resolved.calculateBottomPadding()) } + + @Test + fun `round screens receive an additional ten percent inset`() { + assertEquals(20.dp, additionalRoundScreenPadding(192, isScreenRound = true)) + assertEquals(22.dp, additionalRoundScreenPadding(220, isScreenRound = true)) + assertEquals(0.dp, additionalRoundScreenPadding(192, isScreenRound = false)) + } } From 7788328917eacb7de1a9d16084975e8ec075077d Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:58:03 +0200 Subject: [PATCH 03/13] Keep Wear chat rows clear of controls --- .../java/com/bitchat/watch/ui/ChatScaffold.kt | 14 ++++++------- .../bitchat/watch/ui/WearContentPadding.kt | 21 ------------------- .../watch/ui/WearContentPaddingTest.kt | 15 ------------- 3 files changed, 6 insertions(+), 44 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 7bbbcd77..640eb8cd 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -260,19 +260,17 @@ private fun ChatBody( val layoutDirection = LocalLayoutDirection.current TransformingLazyColumn( state = columnState, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .padding(top = 30.dp, bottom = 64.dp), // Arrangement.Bottom anchors short content to the bottom: the first message // starts just above the action bar and new messages push history upward. - // The padding reserves permanent room for the floating header and action - // bar; being constant, it never disturbs an in-flight scroll gesture. + // The viewport itself stays between the floating header and action bar, so + // rows cannot scroll underneath either control. Its geometry is constant, + // so showing or hiding the overlays never disturbs an in-flight gesture. verticalArrangement = Arrangement.Bottom, contentPadding = scaffoldPadding .withRoundScreenPadding(layoutDirection) - .withMinimumVerticalPadding( - layoutDirection = layoutDirection, - top = 30.dp, - bottom = 64.dp - ) ) { if (messages.isEmpty()) { item { diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt b/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt index 0419bdfa..02737bce 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt @@ -33,27 +33,6 @@ internal fun PaddingValues.withAdditionalPadding( ) } -internal fun PaddingValues.withMinimumVerticalPadding( - layoutDirection: LayoutDirection, - top: Dp, - bottom: Dp -): PaddingValues { - val start = when (layoutDirection) { - LayoutDirection.Ltr -> calculateLeftPadding(layoutDirection) - LayoutDirection.Rtl -> calculateRightPadding(layoutDirection) - } - val end = when (layoutDirection) { - LayoutDirection.Ltr -> calculateRightPadding(layoutDirection) - LayoutDirection.Rtl -> calculateLeftPadding(layoutDirection) - } - return PaddingValues( - start = start, - top = maxOf(calculateTopPadding(), top), - end = end, - bottom = maxOf(calculateBottomPadding(), bottom) - ) -} - @Composable internal fun PaddingValues.withRoundScreenPadding( layoutDirection: LayoutDirection diff --git a/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt b/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt index 3fe5273b..055f062c 100644 --- a/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt +++ b/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt @@ -23,21 +23,6 @@ class WearContentPaddingTest { assertEquals(28.dp, resolved.calculateBottomPadding()) } - @Test - fun `chat padding keeps responsive sides and overlay clearances`() { - val resolved = PaddingValues(start = 10.dp, top = 18.dp, end = 12.dp, bottom = 20.dp) - .withMinimumVerticalPadding( - layoutDirection = LayoutDirection.Ltr, - top = 30.dp, - bottom = 64.dp - ) - - assertEquals(10.dp, resolved.calculateLeftPadding(LayoutDirection.Ltr)) - assertEquals(12.dp, resolved.calculateRightPadding(LayoutDirection.Ltr)) - assertEquals(30.dp, resolved.calculateTopPadding()) - assertEquals(64.dp, resolved.calculateBottomPadding()) - } - @Test fun `round screens receive an additional ten percent inset`() { assertEquals(20.dp, additionalRoundScreenPadding(192, isScreenRound = true)) From 5d66b69129ec0a496bf6ad86be11b4631fc9ee55 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:01:46 +0200 Subject: [PATCH 04/13] Clip Wear messages below the chat header --- .../java/com/bitchat/watch/ui/ChatScaffold.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 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 640eb8cd..f30788a7 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -6,10 +6,12 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -20,6 +22,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.graphicsLayer @@ -35,6 +38,7 @@ import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState import androidx.wear.compose.foundation.lazy.items import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.MaterialTheme import androidx.wear.compose.material3.Text import androidx.wear.compose.material3.lazy.rememberTransformationSpec import androidx.wear.compose.material3.lazy.transformedHeight @@ -262,7 +266,8 @@ private fun ChatBody( state = columnState, modifier = Modifier .fillMaxSize() - .padding(top = 30.dp, bottom = 64.dp), + .padding(top = 30.dp, bottom = 64.dp) + .clipToBounds(), // Arrangement.Bottom anchors short content to the bottom: the first message // starts just above the action bar and new messages push history upward. // The viewport itself stays between the floating header and action bar, so @@ -304,7 +309,14 @@ private fun ChatBody( // The header stays put and shrinks to its dense form instead of disappearing; // as an overlay its size animation never touches the list's scroll geometry. - Box(modifier = Modifier.align(Alignment.TopCenter)) { + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(30.dp) + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.TopCenter + ) { header(controlsVisible) } From 4a1de47ed64f33e614fe7395bf2a7f0ead6e1dca Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:04:46 +0200 Subject: [PATCH 05/13] Cover transformed Wear header boundary --- wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 f30788a7..19e11bd3 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -313,7 +313,9 @@ private fun ChatBody( modifier = Modifier .align(Alignment.TopCenter) .fillMaxWidth() - .height(30.dp) + // Overlap the clipped list viewport by 4dp so transformed glyphs cannot + // leave antialiased remnants exactly on the boundary at larger font scales. + .height(34.dp) .background(MaterialTheme.colorScheme.background), contentAlignment = Alignment.TopCenter ) { From f7440383182ce4c42a1f09985afd8648ad638ea5 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:08:44 +0200 Subject: [PATCH 06/13] Preserve width for Wear Material cards --- wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt | 4 +--- wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt | 4 +--- wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt | 1 - .../main/java/com/bitchat/watch/ui/VerificationCodeScreen.kt | 1 - 4 files changed, 2 insertions(+), 8 deletions(-) diff --git a/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt index bda2c2bd..fa83e59b 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -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 @@ -48,11 +47,10 @@ fun PeerDebugScreen() { val identityRevision by WearPeerIdentityState.revision.collectAsState() ScreenScaffold(scrollState = listState) { scaffoldPadding -> - val layoutDirection = LocalLayoutDirection.current ScalingLazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = scaffoldPadding.withRoundScreenPadding(layoutDirection) + contentPadding = scaffoldPadding ) { item { ListHeader { diff --git a/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt index d8d7df17..a27fab14 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt @@ -17,7 +17,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -69,11 +68,10 @@ fun PeopleScreen(onOpenDm: (String) -> Unit, onEditNickname: () -> Unit) { } ScreenScaffold(scrollState = listState) { scaffoldPadding -> - val layoutDirection = LocalLayoutDirection.current ScalingLazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = scaffoldPadding.withRoundScreenPadding(layoutDirection) + contentPadding = scaffoldPadding ) { item { ListHeader { 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 1188fed0..75f2db9c 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt @@ -54,7 +54,6 @@ fun UserDetailScreen( state = listState, modifier = Modifier.fillMaxSize(), contentPadding = scaffoldPadding - .withRoundScreenPadding(layoutDirection) .withAdditionalPadding( layoutDirection = layoutDirection, horizontal = 10.dp, 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 89e83ee3..104ab660 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/VerificationCodeScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/VerificationCodeScreen.kt @@ -49,7 +49,6 @@ fun VerificationCodeScreen(peerID: String) { state = listState, modifier = Modifier.fillMaxSize(), contentPadding = scaffoldPadding - .withRoundScreenPadding(layoutDirection) .withAdditionalPadding( layoutDirection = layoutDirection, horizontal = 10.dp, From 45915fab8fdd8e5295e695cdeb338a5972987fd3 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:15:17 +0200 Subject: [PATCH 07/13] Tune Wear header boundary by screen shape --- wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 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 19e11bd3..ffc3b0cf 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.graphics.graphicsLayer 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.LocalLayoutDirection import androidx.compose.ui.text.style.TextAlign @@ -189,6 +190,7 @@ private fun ChatBody( val palette = LocalBitchatPalette.current val context = LocalContext.current val transformationSpec = rememberTransformationSpec() + val headerBackdropHeight = if (LocalConfiguration.current.isScreenRound) 35.dp else 37.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 @@ -313,9 +315,9 @@ private fun ChatBody( modifier = Modifier .align(Alignment.TopCenter) .fillMaxWidth() - // Overlap the clipped list viewport by 4dp so transformed glyphs cannot - // leave antialiased remnants exactly on the boundary at larger font scales. - .height(34.dp) + // Overlap the clipped list viewport enough for each shape so transformed + // glyphs cannot leave antialiased remnants exactly on the boundary. + .height(headerBackdropHeight) .background(MaterialTheme.colorScheme.background), contentAlignment = Alignment.TopCenter ) { From 34f94882f5d2077f33b863101e48a5f79f3cb595 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:57:24 +0200 Subject: [PATCH 08/13] Restore Wear chat content space --- .../java/com/bitchat/watch/ui/ChatScaffold.kt | 8 ++-- .../bitchat/watch/ui/WearContentPadding.kt | 38 ++++++++----------- .../watch/ui/WearContentPaddingTest.kt | 12 ++++-- 3 files changed, 29 insertions(+), 29 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 ffc3b0cf..eb0b75ef 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -190,7 +190,7 @@ private fun ChatBody( val palette = LocalBitchatPalette.current val context = LocalContext.current val transformationSpec = rememberTransformationSpec() - val headerBackdropHeight = if (LocalConfiguration.current.isScreenRound) 35.dp else 37.dp + val headerBackdropHeight = if (LocalConfiguration.current.isScreenRound) 49.dp else 37.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 @@ -276,8 +276,10 @@ private fun ChatBody( // rows cannot scroll underneath either control. Its geometry is constant, // so showing or hiding the overlays never disturbs an in-flight gesture. verticalArrangement = Arrangement.Bottom, - contentPadding = scaffoldPadding - .withRoundScreenPadding(layoutDirection) + // Wear Material already supplies a responsive 5.2% horizontal inset. Keep it, + // but omit the scaffold's vertical inset because the viewport itself reserves + // the header and action-bar space. Stacking both made chat narrow and too high. + contentPadding = scaffoldPadding.horizontalOnly(layoutDirection) ) { if (messages.isEmpty()) { item { diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt b/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt index 02737bce..765b9a6f 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt @@ -1,12 +1,9 @@ package com.bitchat.watch.ui import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import kotlin.math.ceil /** * Preserve the responsive, shape-aware padding supplied by Wear Material while allowing a @@ -33,24 +30,21 @@ internal fun PaddingValues.withAdditionalPadding( ) } -@Composable -internal fun PaddingValues.withRoundScreenPadding( +/** + * Keep the responsive horizontal inset supplied by Wear Material without also reserving its + * vertical list padding. Chat owns its fixed header and action-bar clearances, so stacking the + * scaffold's vertical padding on top would unnecessarily shorten the message viewport. + */ +internal fun PaddingValues.horizontalOnly( layoutDirection: LayoutDirection -): PaddingValues = withAdditionalPadding( - layoutDirection = layoutDirection, - horizontal = additionalRoundScreenPadding( - screenWidthDp = LocalConfiguration.current.screenWidthDp, - isScreenRound = LocalConfiguration.current.isScreenRound - ) -) - -internal fun additionalRoundScreenPadding( - screenWidthDp: Int, - isScreenRound: Boolean -): Dp = if (isScreenRound) { - ceil(screenWidthDp * ROUND_SCREEN_ADDITIONAL_PADDING_FRACTION).toInt().dp -} else { - 0.dp +): PaddingValues { + val start = when (layoutDirection) { + LayoutDirection.Ltr -> calculateLeftPadding(layoutDirection) + LayoutDirection.Rtl -> calculateRightPadding(layoutDirection) + } + val end = when (layoutDirection) { + LayoutDirection.Ltr -> calculateRightPadding(layoutDirection) + LayoutDirection.Rtl -> calculateLeftPadding(layoutDirection) + } + return PaddingValues(start = start, end = end) } - -private const val ROUND_SCREEN_ADDITIONAL_PADDING_FRACTION = 0.10f diff --git a/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt b/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt index 055f062c..c9536ba0 100644 --- a/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt +++ b/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt @@ -24,9 +24,13 @@ class WearContentPaddingTest { } @Test - fun `round screens receive an additional ten percent inset`() { - assertEquals(20.dp, additionalRoundScreenPadding(192, isScreenRound = true)) - assertEquals(22.dp, additionalRoundScreenPadding(220, isScreenRound = true)) - assertEquals(0.dp, additionalRoundScreenPadding(192, isScreenRound = false)) + fun `horizontal only padding preserves width inset and restores vertical space`() { + val resolved = PaddingValues(start = 10.dp, top = 20.dp, end = 12.dp, bottom = 20.dp) + .horizontalOnly(LayoutDirection.Ltr) + + assertEquals(10.dp, resolved.calculateLeftPadding(LayoutDirection.Ltr)) + assertEquals(12.dp, resolved.calculateRightPadding(LayoutDirection.Ltr)) + assertEquals(0.dp, resolved.calculateTopPadding()) + assertEquals(0.dp, resolved.calculateBottomPadding()) } } From ed617f0f1b3641255a22ca24dca70dc15df629a1 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:13:07 +0200 Subject: [PATCH 09/13] Fix Wear chat autoscroll and focal scaling --- .../java/com/bitchat/watch/ui/ChatScaffold.kt | 88 ++++++++++++++----- .../bitchat/watch/ui/WearContentPadding.kt | 14 +-- .../bitchat/watch/ui/ChatAutoScrollTest.kt | 40 +++++++++ .../watch/ui/WearContentPaddingTest.kt | 12 ++- 4 files changed, 120 insertions(+), 34 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 eb0b75ef..d373590e 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -22,10 +22,11 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.input.pointer.pointerInput @@ -87,35 +88,41 @@ fun ChatScaffold( previousCount = messages.size } - // One state drives both overlays: visible at the bottom or when scrolling toward it, - // hidden when scrolling up into history. The 24px (~12dp) threshold is deliberately - // small so the controls answer every flick immediately. - var atNewest by remember { mutableStateOf(true) } + // 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) } LaunchedEffect(columnState) { var lastPosition = -1 snapshotFlow { val first = columnState.layoutInfo.visibleItems.firstOrNull() - Triple(columnState.canScrollForward, first?.index ?: 0, first?.offset ?: 0) - }.collect { (canScrollForward, index, offset) -> - val position = index * 100_000 + offset - atNewest = !canScrollForward - if (!canScrollForward) { + ChatScrollSnapshot( + canScrollForward = columnState.canScrollForward, + isScrollInProgress = columnState.isScrollInProgress, + position = (first?.index ?: 0) * 100_000 + (first?.offset ?: 0) + ) + }.collect { snapshot -> + followNewest = updatedFollowNewest( + current = followNewest, + snapshot = snapshot, + previousPosition = lastPosition + ) + if (!snapshot.canScrollForward) { controlsVisible.value = true } else if (lastPosition >= 0) { when { - position > lastPosition + 24 -> controlsVisible.value = true - position < lastPosition - 24 -> controlsVisible.value = false + snapshot.position > lastPosition + CHAT_SCROLL_DIRECTION_THRESHOLD_PX -> + controlsVisible.value = true + snapshot.position < lastPosition - CHAT_SCROLL_DIRECTION_THRESHOLD_PX -> + controlsVisible.value = false } } - lastPosition = position + lastPosition = snapshot.position } } - // Stick to bottom: follow new messages while resting at the newest. - // Capture this when the message count changes, before the new layout can temporarily make - // canScrollForward true and report that the user is browsing history. - val followNewest = remember(messages.size) { atNewest } + // Stick to bottom when the user has not intentionally moved into history. LaunchedEffect(columnState, messages.size) { if (messages.isNotEmpty() && followNewest) { val expectedSingleMessageKey = messages.singleOrNull()?.id @@ -160,6 +167,24 @@ internal data class MeasuredChatLayout( val singleVisibleItemKey: Any? ) +internal data class ChatScrollSnapshot( + val canScrollForward: Boolean, + val isScrollInProgress: Boolean, + val position: Int +) + +internal fun updatedFollowNewest( + current: Boolean, + snapshot: ChatScrollSnapshot, + previousPosition: Int +): Boolean = when { + !snapshot.canScrollForward -> true + snapshot.isScrollInProgress && + previousPosition >= 0 && + snapshot.position < previousPosition - CHAT_SCROLL_DIRECTION_THRESHOLD_PX -> false + else -> current +} + internal suspend fun scrollToNewestAfterItemsMeasured( expectedItemCount: Int, expectedSingleMessageKey: Any?, @@ -268,18 +293,30 @@ private fun ChatBody( state = columnState, modifier = Modifier .fillMaxSize() - .padding(top = 30.dp, bottom = 64.dp) - .clipToBounds(), + // Keep full-screen measurement so Wear's transformation focal point and + // scroll range stay correct. Only drawing is clipped around the overlays. + .drawWithContent { + clipRect( + top = headerBackdropHeight.toPx(), + bottom = size.height - CHAT_ACTION_BAR_CLEARANCE.toPx() + ) { + this@drawWithContent.drawContent() + } + }, // Arrangement.Bottom anchors short content to the bottom: the first message // starts just above the action bar and new messages push history upward. // The viewport itself stays between the floating header and action bar, so - // rows cannot scroll underneath either control. Its geometry is constant, - // so showing or hiding the overlays never disturbs an in-flight gesture. + // content stays clear of both controls while the full-screen list geometry keeps + // autoscroll and the native transformation focal point intact. verticalArrangement = Arrangement.Bottom, // Wear Material already supplies a responsive 5.2% horizontal inset. Keep it, - // but omit the scaffold's vertical inset because the viewport itself reserves - // the header and action-bar space. Stacking both made chat narrow and too high. - contentPadding = scaffoldPadding.horizontalOnly(layoutDirection) + // while replacing its vertical inset with the overlay clearances used before the + // shape fix. This avoids both duplicated padding and a shortened list viewport. + contentPadding = scaffoldPadding.withVerticalClearance( + layoutDirection = layoutDirection, + top = CHAT_HEADER_CONTENT_CLEARANCE, + bottom = CHAT_ACTION_BAR_CLEARANCE + ) ) { if (messages.isEmpty()) { item { @@ -356,6 +393,9 @@ 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 // Magnetic zone geometry (px at watch density): the button starts reacting at // MAGNET_OUTER_PX from its center and fully blushes at MAGNET_INNER_PX (~the activation // boundary); it leans toward the finger by up to MAGNET_PULL_PX. diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt b/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt index 765b9a6f..e24671ff 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt @@ -31,12 +31,14 @@ internal fun PaddingValues.withAdditionalPadding( } /** - * Keep the responsive horizontal inset supplied by Wear Material without also reserving its - * vertical list padding. Chat owns its fixed header and action-bar clearances, so stacking the - * scaffold's vertical padding on top would unnecessarily shorten the message viewport. + * Keep the responsive horizontal inset supplied by Wear Material while replacing its vertical + * padding with screen-owned clearances. This keeps the lazy list full-screen for correct Wear + * transformation and scroll calculations without stacking duplicate vertical insets. */ -internal fun PaddingValues.horizontalOnly( - layoutDirection: LayoutDirection +internal fun PaddingValues.withVerticalClearance( + layoutDirection: LayoutDirection, + top: Dp, + bottom: Dp ): PaddingValues { val start = when (layoutDirection) { LayoutDirection.Ltr -> calculateLeftPadding(layoutDirection) @@ -46,5 +48,5 @@ internal fun PaddingValues.horizontalOnly( LayoutDirection.Ltr -> calculateRightPadding(layoutDirection) LayoutDirection.Rtl -> calculateLeftPadding(layoutDirection) } - return PaddingValues(start = start, end = end) + return PaddingValues(start = start, top = top, end = end, bottom = bottom) } 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 0b3f85b5..ad7c687a 100644 --- a/wear/src/test/java/com/bitchat/watch/ui/ChatAutoScrollTest.kt +++ b/wear/src/test/java/com/bitchat/watch/ui/ChatAutoScrollTest.kt @@ -10,6 +10,46 @@ import org.junit.Test class ChatAutoScrollTest { + @Test + fun `new scroll range from appended message keeps follow intent`() { + val updated = updatedFollowNewest( + current = true, + snapshot = ChatScrollSnapshot( + canScrollForward = true, + isScrollInProgress = false, + position = 100 + ), + previousPosition = 100 + ) + + assertEquals(true, updated) + } + + @Test + fun `user scroll away disables follow until list reaches newest again`() { + val browsingHistory = updatedFollowNewest( + current = true, + snapshot = ChatScrollSnapshot( + canScrollForward = true, + isScrollInProgress = true, + position = 60 + ), + previousPosition = 100 + ) + assertFalse(browsingHistory) + + val dockedAgain = updatedFollowNewest( + current = browsingHistory, + snapshot = ChatScrollSnapshot( + canScrollForward = false, + isScrollInProgress = false, + position = 200 + ), + previousPosition = 60 + ) + assertEquals(true, dockedAgain) + } + @Test fun `newest scroll waits until appended message is measured`() = runTest { val measuredLayouts = MutableStateFlow(MeasuredChatLayout(3, null)) diff --git a/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt b/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt index c9536ba0..477fad77 100644 --- a/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt +++ b/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt @@ -24,13 +24,17 @@ class WearContentPaddingTest { } @Test - fun `horizontal only padding preserves width inset and restores vertical space`() { + fun `vertical clearance preserves responsive width and replaces scaffold height inset`() { val resolved = PaddingValues(start = 10.dp, top = 20.dp, end = 12.dp, bottom = 20.dp) - .horizontalOnly(LayoutDirection.Ltr) + .withVerticalClearance( + layoutDirection = LayoutDirection.Ltr, + top = 30.dp, + bottom = 64.dp + ) assertEquals(10.dp, resolved.calculateLeftPadding(LayoutDirection.Ltr)) assertEquals(12.dp, resolved.calculateRightPadding(LayoutDirection.Ltr)) - assertEquals(0.dp, resolved.calculateTopPadding()) - assertEquals(0.dp, resolved.calculateBottomPadding()) + assertEquals(30.dp, resolved.calculateTopPadding()) + assertEquals(64.dp, resolved.calculateBottomPadding()) } } From 898cfde066f4049bcfb9738ec5ef32e29c1d165e Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:39:16 +0200 Subject: [PATCH 10/13] Restore Wear chat edge fading --- .../java/com/bitchat/watch/ui/ChatScaffold.kt | 37 +++++-------------- 1 file changed, 9 insertions(+), 28 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 d373590e..17a3101c 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -6,12 +6,10 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -22,15 +20,12 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.drawscope.clipRect 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.LocalLayoutDirection import androidx.compose.ui.text.style.TextAlign @@ -40,7 +35,6 @@ import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState import androidx.wear.compose.foundation.lazy.items import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState import androidx.wear.compose.material3.ScreenScaffold -import androidx.wear.compose.material3.MaterialTheme import androidx.wear.compose.material3.Text import androidx.wear.compose.material3.lazy.rememberTransformationSpec import androidx.wear.compose.material3.lazy.transformedHeight @@ -215,8 +209,6 @@ private fun ChatBody( val palette = LocalBitchatPalette.current val context = LocalContext.current val transformationSpec = rememberTransformationSpec() - val headerBackdropHeight = if (LocalConfiguration.current.isScreenRound) 49.dp else 37.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). @@ -291,23 +283,16 @@ private fun ChatBody( val layoutDirection = LocalLayoutDirection.current TransformingLazyColumn( state = columnState, - modifier = Modifier - .fillMaxSize() - // Keep full-screen measurement so Wear's transformation focal point and - // scroll range stay correct. Only drawing is clipped around the overlays. - .drawWithContent { - clipRect( - top = headerBackdropHeight.toPx(), - bottom = size.height - CHAT_ACTION_BAR_CLEARANCE.toPx() - ) { - this@drawWithContent.drawContent() - } - }, + // Keep the list full-screen and unclipped. Wear's transformation spec scales + // and fades rows naturally along the round display edge, including underneath + // the transparent header and floating action controls. + modifier = Modifier.fillMaxSize(), // Arrangement.Bottom anchors short content to the bottom: the first message // starts just above the action bar and new messages push history upward. - // The viewport itself stays between the floating header and action bar, so - // content stays clear of both controls while the full-screen list geometry keeps - // autoscroll and the native transformation focal point intact. + // The scroll range reserves resting space for the floating controls while the + // full-screen viewport preserves autoscroll and the native transformation focal + // point. Rows may travel behind the overlays only after they have begun the Wear + // edge scale/fade treatment. verticalArrangement = Arrangement.Bottom, // Wear Material already supplies a responsive 5.2% horizontal inset. Keep it, // while replacing its vertical inset with the overlay clearances used before the @@ -353,11 +338,7 @@ private fun ChatBody( Box( modifier = Modifier .align(Alignment.TopCenter) - .fillMaxWidth() - // Overlap the clipped list viewport enough for each shape so transformed - // glyphs cannot leave antialiased remnants exactly on the boundary. - .height(headerBackdropHeight) - .background(MaterialTheme.colorScheme.background), + .fillMaxWidth(), contentAlignment = Alignment.TopCenter ) { header(controlsVisible) From 3ed46222baf4c91c2cb7ba61920be022146408d6 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:56:00 +0200 Subject: [PATCH 11/13] Fade Wear chat at screen contour --- .../java/com/bitchat/watch/ui/ChatScaffold.kt | 52 +++++++++++++++++-- 1 file changed, 48 insertions(+), 4 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 17a3101c..bec5125d 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -20,12 +20,18 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.BlendMode +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.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.LocalLayoutDirection import androidx.compose.ui.text.style.TextAlign @@ -209,6 +215,7 @@ private fun ChatBody( val palette = LocalBitchatPalette.current val context = LocalContext.current val transformationSpec = rememberTransformationSpec() + val isScreenRound = LocalConfiguration.current.isScreenRound // 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). @@ -283,10 +290,44 @@ private fun ChatBody( val layoutDirection = LocalLayoutDirection.current TransformingLazyColumn( state = columnState, - // Keep the list full-screen and unclipped. Wear's transformation spec scales - // and fades rows naturally along the round display edge, including underneath - // the transparent header and floating action controls. - modifier = Modifier.fillMaxSize(), + // Keep the list full-screen and geometrically unclipped. Wear's transformation + // spec curves rows along the round display; a soft destination-alpha mask then + // makes them fully transparent at the physical edges instead of cutting glyphs. + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen + } + .drawWithContent { + drawContent() + val edgeMask = if (isScreenRound) { + // Fade toward the actual circular contour so glyphs become + // transparent before the panel can crop their left or right edge. + Brush.radialGradient( + 0f to Color.Black, + CHAT_ROUND_EDGE_OPAQUE_STOP to Color.Black, + 1f to Color.Transparent, + center = Offset(size.width / 2f, size.height / 2f), + radius = size.minDimension / 2f + ) + } else { + val topOpaqueStop = + (CHAT_HEADER_EDGE_FADE.toPx() / size.height).coerceIn(0f, 1f) + val bottomOpaqueStop = + (1f - CHAT_ACTION_BAR_EDGE_FADE.toPx() / size.height) + .coerceIn(topOpaqueStop, 1f) + Brush.verticalGradient( + 0f to Color.Transparent, + topOpaqueStop to Color.Black, + bottomOpaqueStop to Color.Black, + 1f to Color.Transparent + ) + } + drawRect( + brush = edgeMask, + blendMode = BlendMode.DstIn + ) + }, // Arrangement.Bottom anchors short content to the bottom: the first message // starts just above the action bar and new messages push history upward. // The scroll range reserves resting space for the floating controls while the @@ -377,6 +418,9 @@ 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 +private const val CHAT_ROUND_EDGE_OPAQUE_STOP = 0.78f // Magnetic zone geometry (px at watch density): the button starts reacting at // MAGNET_OUTER_PX from its center and fully blushes at MAGNET_INNER_PX (~the activation // boundary); it leans toward the finger by up to MAGNET_PULL_PX. From a125da48aabbfedc922d298f323543ee79c54486 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:46:13 +0200 Subject: [PATCH 12/13] Polish Wear shape-aware layout implementation --- .../java/com/bitchat/watch/ui/ChatScaffold.kt | 22 +++++++------- .../com/bitchat/watch/ui/PeerDebugScreen.kt | 5 ++-- .../bitchat/watch/ui/WearContentPadding.kt | 29 +++++++++--------- .../watch/ui/WearContentPaddingTest.kt | 30 +++++++++++++++++++ 4 files changed, 59 insertions(+), 27 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 bec5125d..15b18436 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -20,7 +20,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.BlendMode @@ -298,8 +298,7 @@ private fun ChatBody( .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } - .drawWithContent { - drawContent() + .drawWithCache { val edgeMask = if (isScreenRound) { // Fade toward the actual circular contour so glyphs become // transparent before the panel can crop their left or right edge. @@ -323,10 +322,13 @@ private fun ChatBody( 1f to Color.Transparent ) } - drawRect( - brush = edgeMask, - blendMode = BlendMode.DstIn - ) + onDrawWithContent { + drawContent() + drawRect( + brush = edgeMask, + blendMode = BlendMode.DstIn + ) + } }, // Arrangement.Bottom anchors short content to the bottom: the first message // starts just above the action bar and new messages push history upward. @@ -335,9 +337,9 @@ private fun ChatBody( // point. Rows may travel behind the overlays only after they have begun the Wear // edge scale/fade treatment. verticalArrangement = Arrangement.Bottom, - // Wear Material already supplies a responsive 5.2% horizontal inset. Keep it, - // while replacing its vertical inset with the overlay clearances used before the - // shape fix. This avoids both duplicated padding and a shortened list viewport. + // Keep Wear Material's responsive horizontal inset while replacing its vertical + // inset with the overlay clearances used before the shape fix. This avoids both + // duplicated padding and a shortened list viewport. contentPadding = scaffoldPadding.withVerticalClearance( layoutDirection = layoutDirection, top = CHAT_HEADER_CONTENT_CLEARANCE, diff --git a/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt index fa83e59b..1994f976 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt @@ -46,11 +46,10 @@ fun PeerDebugScreen() { val rssi = mesh?.getPeerRSSI() ?: emptyMap() val identityRevision by WearPeerIdentityState.revision.collectAsState() - ScreenScaffold(scrollState = listState) { scaffoldPadding -> + ScreenScaffold(scrollState = listState) { ScalingLazyColumn( state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = scaffoldPadding + modifier = Modifier.fillMaxSize() ) { item { ListHeader { diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt b/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt index e24671ff..738e56cf 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt @@ -14,18 +14,10 @@ internal fun PaddingValues.withAdditionalPadding( horizontal: Dp = 0.dp, vertical: Dp = 0.dp ): PaddingValues { - val start = when (layoutDirection) { - LayoutDirection.Ltr -> calculateLeftPadding(layoutDirection) - LayoutDirection.Rtl -> calculateRightPadding(layoutDirection) - } - val end = when (layoutDirection) { - LayoutDirection.Ltr -> calculateRightPadding(layoutDirection) - LayoutDirection.Rtl -> calculateLeftPadding(layoutDirection) - } return PaddingValues( - start = start + horizontal, + start = calculateStartPadding(layoutDirection) + horizontal, top = calculateTopPadding() + vertical, - end = end + horizontal, + end = calculateEndPadding(layoutDirection) + horizontal, bottom = calculateBottomPadding() + vertical ) } @@ -40,13 +32,22 @@ internal fun PaddingValues.withVerticalClearance( top: Dp, bottom: Dp ): PaddingValues { - val start = when (layoutDirection) { + return PaddingValues( + start = calculateStartPadding(layoutDirection), + top = top, + end = calculateEndPadding(layoutDirection), + bottom = bottom + ) +} + +private fun PaddingValues.calculateStartPadding(layoutDirection: LayoutDirection): Dp = + when (layoutDirection) { LayoutDirection.Ltr -> calculateLeftPadding(layoutDirection) LayoutDirection.Rtl -> calculateRightPadding(layoutDirection) } - val end = when (layoutDirection) { + +private fun PaddingValues.calculateEndPadding(layoutDirection: LayoutDirection): Dp = + when (layoutDirection) { LayoutDirection.Ltr -> calculateRightPadding(layoutDirection) LayoutDirection.Rtl -> calculateLeftPadding(layoutDirection) } - return PaddingValues(start = start, top = top, end = end, bottom = bottom) -} diff --git a/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt b/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt index 477fad77..60191d67 100644 --- a/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt +++ b/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt @@ -37,4 +37,34 @@ class WearContentPaddingTest { assertEquals(30.dp, resolved.calculateTopPadding()) assertEquals(64.dp, resolved.calculateBottomPadding()) } + + @Test + fun `additional padding preserves logical start and end in rtl`() { + val resolved = PaddingValues(start = 10.dp, top = 18.dp, end = 12.dp, bottom = 20.dp) + .withAdditionalPadding( + layoutDirection = LayoutDirection.Rtl, + horizontal = 10.dp, + vertical = 8.dp + ) + + assertEquals(22.dp, resolved.calculateLeftPadding(LayoutDirection.Rtl)) + assertEquals(20.dp, resolved.calculateRightPadding(LayoutDirection.Rtl)) + assertEquals(26.dp, resolved.calculateTopPadding()) + assertEquals(28.dp, resolved.calculateBottomPadding()) + } + + @Test + fun `vertical clearance preserves logical start and end in rtl`() { + val resolved = PaddingValues(start = 10.dp, top = 20.dp, end = 12.dp, bottom = 20.dp) + .withVerticalClearance( + layoutDirection = LayoutDirection.Rtl, + top = 30.dp, + bottom = 64.dp + ) + + assertEquals(12.dp, resolved.calculateLeftPadding(LayoutDirection.Rtl)) + assertEquals(10.dp, resolved.calculateRightPadding(LayoutDirection.Rtl)) + assertEquals(30.dp, resolved.calculateTopPadding()) + assertEquals(64.dp, resolved.calculateBottomPadding()) + } } From 7cf5286bfbc2c2085083f78874d586fa1f5f6c02 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:28:17 +0200 Subject: [PATCH 13/13] Fix slow Wear scroll intent detection --- .../java/com/bitchat/watch/ui/ChatScaffold.kt | 60 ++++++++++------ .../bitchat/watch/ui/ChatAutoScrollTest.kt | 72 ++++++++++++++++--- 2 files changed, 104 insertions(+), 28 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 15b18436..f304446e 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -50,6 +50,7 @@ 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 @@ -95,6 +96,7 @@ fun ChatScaffold( val controlsVisible = remember { mutableStateOf(true) } LaunchedEffect(columnState) { var lastPosition = -1 + var scrollIntent = ChatScrollIntentState() snapshotFlow { val first = columnState.layoutInfo.visibleItems.firstOrNull() ChatScrollSnapshot( @@ -103,21 +105,13 @@ fun ChatScaffold( position = (first?.index ?: 0) * 100_000 + (first?.offset ?: 0) ) }.collect { snapshot -> - followNewest = updatedFollowNewest( - current = followNewest, + scrollIntent = updatedChatScrollIntent( + current = scrollIntent, snapshot = snapshot, previousPosition = lastPosition ) - if (!snapshot.canScrollForward) { - controlsVisible.value = true - } else if (lastPosition >= 0) { - when { - snapshot.position > lastPosition + CHAT_SCROLL_DIRECTION_THRESHOLD_PX -> - controlsVisible.value = true - snapshot.position < lastPosition - CHAT_SCROLL_DIRECTION_THRESHOLD_PX -> - controlsVisible.value = false - } - } + followNewest = scrollIntent.followsNewest + controlsVisible.value = scrollIntent.controlsVisible lastPosition = snapshot.position } } @@ -173,16 +167,42 @@ internal data class ChatScrollSnapshot( val position: Int ) -internal fun updatedFollowNewest( - current: Boolean, +internal data class ChatScrollIntentState( + val followsNewest: Boolean = true, + val controlsVisible: Boolean = true, + val accumulatedDeltaPx: Int = 0 +) + +internal fun updatedChatScrollIntent( + current: ChatScrollIntentState, snapshot: ChatScrollSnapshot, previousPosition: Int -): Boolean = when { - !snapshot.canScrollForward -> true - snapshot.isScrollInProgress && - previousPosition >= 0 && - snapshot.position < previousPosition - CHAT_SCROLL_DIRECTION_THRESHOLD_PX -> false - else -> current +): 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 + + 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 + ) } internal suspend fun scrollToNewestAfterItemsMeasured( 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 ad7c687a..7b2d13b9 100644 --- a/wear/src/test/java/com/bitchat/watch/ui/ChatAutoScrollTest.kt +++ b/wear/src/test/java/com/bitchat/watch/ui/ChatAutoScrollTest.kt @@ -12,8 +12,8 @@ class ChatAutoScrollTest { @Test fun `new scroll range from appended message keeps follow intent`() { - val updated = updatedFollowNewest( - current = true, + val updated = updatedChatScrollIntent( + current = ChatScrollIntentState(), snapshot = ChatScrollSnapshot( canScrollForward = true, isScrollInProgress = false, @@ -22,13 +22,13 @@ class ChatAutoScrollTest { previousPosition = 100 ) - assertEquals(true, updated) + assertEquals(ChatScrollIntentState(), updated) } @Test fun `user scroll away disables follow until list reaches newest again`() { - val browsingHistory = updatedFollowNewest( - current = true, + val browsingHistory = updatedChatScrollIntent( + current = ChatScrollIntentState(), snapshot = ChatScrollSnapshot( canScrollForward = true, isScrollInProgress = true, @@ -36,9 +36,10 @@ class ChatAutoScrollTest { ), previousPosition = 100 ) - assertFalse(browsingHistory) + assertFalse(browsingHistory.followsNewest) + assertFalse(browsingHistory.controlsVisible) - val dockedAgain = updatedFollowNewest( + val dockedAgain = updatedChatScrollIntent( current = browsingHistory, snapshot = ChatScrollSnapshot( canScrollForward = false, @@ -47,7 +48,62 @@ class ChatAutoScrollTest { ), previousPosition = 60 ) - assertEquals(true, dockedAgain) + assertEquals(ChatScrollIntentState(), dockedAgain) + } + + @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 + } + + assertFalse(state.followsNewest) + assertEquals(true, state.controlsVisible) + assertEquals(0, state.accumulatedDeltaPx) } @Test