mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Merge pull request #916 from permissionlesstech/codex/fix-wear-shape-and-font-scaling
Improve Wear layouts for round screens
This commit is contained in:
commit
4f828b5643
@ -19,8 +19,8 @@ android {
|
|||||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||||
// Wear releases use a separate high range because Play requires every artifact in
|
// 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.
|
// one application ID to have a unique version code across all form factors.
|
||||||
versionCode = 1_000_000_002
|
versionCode = 1_000_000_003
|
||||||
versionName = "0.1.1"
|
versionName = "0.1.2"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import androidx.compose.animation.slideInVertically
|
|||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@ -21,13 +20,20 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Rect
|
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.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
import androidx.compose.ui.input.pointer.changedToUp
|
import androidx.compose.ui.input.pointer.changedToUp
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
|
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 com.bitchat.watch.ui.theme.LocalBitchatPalette
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlin.math.sign
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shared chat body for global chat and DM threads, following the classic messenger
|
* The shared chat body for global chat and DM threads, following the classic messenger
|
||||||
@ -82,35 +89,34 @@ fun ChatScaffold(
|
|||||||
previousCount = messages.size
|
previousCount = messages.size
|
||||||
}
|
}
|
||||||
|
|
||||||
// One state drives both overlays: visible at the bottom or when scrolling toward it,
|
// Follow intent is changed only by an actual user scroll away from the newest item or by
|
||||||
// hidden when scrolling up into history. The 24px (~12dp) threshold is deliberately
|
// reaching the end again. A new item temporarily makes canScrollForward true before layout;
|
||||||
// small so the controls answer every flick immediately.
|
// treating that transient range change as user intent breaks automatic following.
|
||||||
var atNewest by remember { mutableStateOf(true) }
|
var followNewest by remember { mutableStateOf(true) }
|
||||||
val controlsVisible = remember { mutableStateOf(true) }
|
val controlsVisible = remember { mutableStateOf(true) }
|
||||||
LaunchedEffect(columnState) {
|
LaunchedEffect(columnState) {
|
||||||
var lastPosition = -1
|
var lastPosition = -1
|
||||||
|
var scrollIntent = ChatScrollIntentState()
|
||||||
snapshotFlow {
|
snapshotFlow {
|
||||||
val first = columnState.layoutInfo.visibleItems.firstOrNull()
|
val first = columnState.layoutInfo.visibleItems.firstOrNull()
|
||||||
Triple(columnState.canScrollForward, first?.index ?: 0, first?.offset ?: 0)
|
ChatScrollSnapshot(
|
||||||
}.collect { (canScrollForward, index, offset) ->
|
canScrollForward = columnState.canScrollForward,
|
||||||
val position = index * 100_000 + offset
|
isScrollInProgress = columnState.isScrollInProgress,
|
||||||
atNewest = !canScrollForward
|
position = (first?.index ?: 0) * 100_000 + (first?.offset ?: 0)
|
||||||
if (!canScrollForward) {
|
)
|
||||||
controlsVisible.value = true
|
}.collect { snapshot ->
|
||||||
} else if (lastPosition >= 0) {
|
scrollIntent = updatedChatScrollIntent(
|
||||||
when {
|
current = scrollIntent,
|
||||||
position > lastPosition + 24 -> controlsVisible.value = true
|
snapshot = snapshot,
|
||||||
position < lastPosition - 24 -> controlsVisible.value = false
|
previousPosition = lastPosition
|
||||||
}
|
)
|
||||||
}
|
followNewest = scrollIntent.followsNewest
|
||||||
lastPosition = position
|
controlsVisible.value = scrollIntent.controlsVisible
|
||||||
|
lastPosition = snapshot.position
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stick to bottom: follow new messages while resting at the newest.
|
// Stick to bottom when the user has not intentionally moved into history.
|
||||||
// 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 }
|
|
||||||
LaunchedEffect(columnState, messages.size) {
|
LaunchedEffect(columnState, messages.size) {
|
||||||
if (messages.isNotEmpty() && followNewest) {
|
if (messages.isNotEmpty() && followNewest) {
|
||||||
val expectedSingleMessageKey = messages.singleOrNull()?.id
|
val expectedSingleMessageKey = messages.singleOrNull()?.id
|
||||||
@ -155,6 +161,50 @@ internal data class MeasuredChatLayout(
|
|||||||
val singleVisibleItemKey: Any?
|
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(
|
internal suspend fun scrollToNewestAfterItemsMeasured(
|
||||||
expectedItemCount: Int,
|
expectedItemCount: Int,
|
||||||
expectedSingleMessageKey: Any?,
|
expectedSingleMessageKey: Any?,
|
||||||
@ -185,7 +235,7 @@ private fun ChatBody(
|
|||||||
val palette = LocalBitchatPalette.current
|
val palette = LocalBitchatPalette.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val transformationSpec = rememberTransformationSpec()
|
val transformationSpec = rememberTransformationSpec()
|
||||||
|
val isScreenRound = LocalConfiguration.current.isScreenRound
|
||||||
// Slide-to-cancel: while recording, the finger's position is tracked globally; the
|
// 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
|
// 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).
|
// 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(
|
TransformingLazyColumn(
|
||||||
state = columnState,
|
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
|
// Arrangement.Bottom anchors short content to the bottom: the first message
|
||||||
// starts just above the action bar and new messages push history upward.
|
// starts just above the action bar and new messages push history upward.
|
||||||
// The padding reserves permanent room for the floating header and action
|
// The scroll range reserves resting space for the floating controls while the
|
||||||
// bar; being constant, it never disturbs an in-flight scroll gesture.
|
// 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,
|
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()) {
|
if (messages.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
@ -299,7 +398,12 @@ private fun ChatBody(
|
|||||||
|
|
||||||
// The header stays put and shrinks to its dense form instead of disappearing;
|
// 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.
|
// 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)
|
header(controlsVisible)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -333,6 +437,12 @@ private fun ChatBody(
|
|||||||
// Extra finger slack (px, ~28dp at watch density) around the cancel target so the snap
|
// 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.
|
// engages as the finger approaches, not only on exact contact.
|
||||||
private const val CANCEL_HOVER_SLANT_PX = 56f
|
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
|
// 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
|
// 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.
|
// boundary); it leans toward the finger by up to MAGNET_PULL_PX.
|
||||||
|
|||||||
@ -67,10 +67,11 @@ fun PeopleScreen(onOpenDm: (String) -> Unit, onEditNickname: () -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ScreenScaffold(scrollState = listState) {
|
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
|
||||||
ScalingLazyColumn(
|
ScalingLazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = scaffoldPadding
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
ListHeader {
|
ListHeader {
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
package com.bitchat.watch.ui
|
package com.bitchat.watch.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@ -15,6 +14,7 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@ -48,11 +48,17 @@ fun UserDetailScreen(
|
|||||||
val listState = rememberScalingLazyListState()
|
val listState = rememberScalingLazyListState()
|
||||||
val palette = LocalBitchatPalette.current
|
val palette = LocalBitchatPalette.current
|
||||||
|
|
||||||
ScreenScaffold(scrollState = listState) {
|
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
|
||||||
|
val layoutDirection = LocalLayoutDirection.current
|
||||||
ScalingLazyColumn(
|
ScalingLazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp)
|
contentPadding = scaffoldPadding
|
||||||
|
.withAdditionalPadding(
|
||||||
|
layoutDirection = layoutDirection,
|
||||||
|
horizontal = 10.dp,
|
||||||
|
vertical = 8.dp
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
ListHeader {
|
ListHeader {
|
||||||
|
|||||||
@ -2,7 +2,6 @@ package com.bitchat.watch.ui
|
|||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@ -14,6 +13,7 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@ -43,11 +43,17 @@ fun VerificationCodeScreen(peerID: String) {
|
|||||||
val listState = rememberScalingLazyListState()
|
val listState = rememberScalingLazyListState()
|
||||||
val palette = LocalBitchatPalette.current
|
val palette = LocalBitchatPalette.current
|
||||||
|
|
||||||
ScreenScaffold(scrollState = listState) {
|
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
|
||||||
|
val layoutDirection = LocalLayoutDirection.current
|
||||||
ScalingLazyColumn(
|
ScalingLazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp)
|
contentPadding = scaffoldPadding
|
||||||
|
.withAdditionalPadding(
|
||||||
|
layoutDirection = layoutDirection,
|
||||||
|
horizontal = 10.dp,
|
||||||
|
vertical = 8.dp
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
ListHeader {
|
ListHeader {
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
@ -10,6 +10,102 @@ import org.junit.Test
|
|||||||
|
|
||||||
class ChatAutoScrollTest {
|
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
|
@Test
|
||||||
fun `newest scroll waits until appended message is measured`() = runTest {
|
fun `newest scroll waits until appended message is measured`() = runTest {
|
||||||
val measuredLayouts = MutableStateFlow(MeasuredChatLayout(3, null))
|
val measuredLayouts = MutableStateFlow(MeasuredChatLayout(3, null))
|
||||||
|
|||||||
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user