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..f304446e 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 @@ -21,13 +20,20 @@ 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.drawWithCache 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 import androidx.compose.ui.unit.dp import androidx.wear.compose.foundation.lazy.TransformingLazyColumn @@ -44,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 @@ -82,35 +89,34 @@ 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 + var scrollIntent = ChatScrollIntentState() 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) { - controlsVisible.value = true - } else if (lastPosition >= 0) { - when { - position > lastPosition + 24 -> controlsVisible.value = true - position < lastPosition - 24 -> controlsVisible.value = false - } - } - lastPosition = position + 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 } } - // 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 @@ -155,6 +161,50 @@ 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 +) + +internal fun updatedChatScrollIntent( + current: ChatScrollIntentState, + snapshot: ChatScrollSnapshot, + previousPosition: Int +): 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( expectedItemCount: Int, expectedSingleMessageKey: Any?, @@ -185,7 +235,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). @@ -256,16 +306,65 @@ private fun ChatBody( } } ) { - ScreenScaffold(scrollState = columnState) { + ScreenScaffold(scrollState = columnState) { scaffoldPadding -> + val layoutDirection = LocalLayoutDirection.current TransformingLazyColumn( state = columnState, - 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 + } + .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. + 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 + ) + } + 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. - // The padding reserves permanent room for the floating header and action - // bar; being constant, it never disturbs an in-flight scroll gesture. + // 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, - contentPadding = PaddingValues(top = 30.dp, bottom = 64.dp) + // 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, + bottom = CHAT_ACTION_BAR_CLEARANCE + ) ) { if (messages.isEmpty()) { item { @@ -299,7 +398,12 @@ 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(), + contentAlignment = Alignment.TopCenter + ) { header(controlsVisible) } @@ -333,6 +437,12 @@ private fun ChatBody( // Extra finger slack (px, ~28dp at watch density) around the cancel target so the snap // engages as the finger approaches, not only on exact contact. private const val CANCEL_HOVER_SLANT_PX = 56f +private const val CHAT_SCROLL_DIRECTION_THRESHOLD_PX = 24 +private val CHAT_HEADER_CONTENT_CLEARANCE = 30.dp +private val CHAT_ACTION_BAR_CLEARANCE = 64.dp +private val CHAT_HEADER_EDGE_FADE = 36.dp +private val CHAT_ACTION_BAR_EDGE_FADE = 72.dp +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. 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..a27fab14 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) { scaffoldPadding -> ScalingLazyColumn( state = listState, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + 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 a36c7ec6..75f2db9c 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,17 @@ 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..104ab660 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,17 @@ 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..738e56cf --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt @@ -0,0 +1,53 @@ +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 { + return PaddingValues( + start = calculateStartPadding(layoutDirection) + horizontal, + top = calculateTopPadding() + vertical, + end = calculateEndPadding(layoutDirection) + horizontal, + bottom = calculateBottomPadding() + vertical + ) +} + +/** + * 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.withVerticalClearance( + layoutDirection: LayoutDirection, + top: Dp, + bottom: Dp +): PaddingValues { + 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) + } + +private fun PaddingValues.calculateEndPadding(layoutDirection: LayoutDirection): Dp = + when (layoutDirection) { + LayoutDirection.Ltr -> calculateRightPadding(layoutDirection) + LayoutDirection.Rtl -> calculateLeftPadding(layoutDirection) + } 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..7b2d13b9 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,102 @@ 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) + } + + @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) + + val dockedAgain = updatedChatScrollIntent( + current = browsingHistory, + snapshot = ChatScrollSnapshot( + canScrollForward = false, + isScrollInProgress = false, + position = 200 + ), + previousPosition = 60 + ) + 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 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 new file mode 100644 index 00000000..60191d67 --- /dev/null +++ b/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt @@ -0,0 +1,70 @@ +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 `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) + .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(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()) + } +}