mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Compact Wear chat headers and stabilize scroll controls
This commit is contained in:
parent
afad3c5513
commit
08974107dc
@ -28,14 +28,19 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
|
||||
import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState
|
||||
import androidx.wear.compose.foundation.lazy.items
|
||||
@ -50,13 +55,12 @@ import com.bitchat.watch.ui.theme.ChatVisualTokens
|
||||
import com.bitchat.watch.ui.theme.LocalBitchatPalette
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlin.math.sign
|
||||
|
||||
/**
|
||||
* The shared chat body for global chat and DM threads, following the classic messenger
|
||||
* pattern: a TransformingLazyColumn message list (native Wear center-scaling/fade, rotary,
|
||||
* scrollbar) with the header and action bar as floating overlays that get out of the way
|
||||
* while scrolling up into history and return on any downward scroll; at the newest message
|
||||
* while scrolling up into history and return on a deliberate reverse scroll; at the newest message
|
||||
* they are always visible.
|
||||
*
|
||||
* The list's contentPadding is CONSTANT and both overlays are layout-neutral, so showing or
|
||||
@ -92,33 +96,38 @@ fun ChatScaffold(
|
||||
// Follow intent is changed only by an actual user scroll away from the newest item or by
|
||||
// reaching the end again. A new item temporarily makes canScrollForward true before layout;
|
||||
// treating that transient range change as user intent breaks automatic following.
|
||||
var followNewest by remember { mutableStateOf(true) }
|
||||
val controlsVisible = remember { mutableStateOf(true) }
|
||||
var scrollIntent by remember { mutableStateOf(ChatScrollIntentState()) }
|
||||
val density = LocalDensity.current
|
||||
val scrollConnection = remember(columnState, density) {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
// Both Wear rotary and touch dispatch consumed movement here. Positive list
|
||||
// movement is toward newer messages. Layout changes never enter this path.
|
||||
scrollIntent = updatedChatScrollIntent(
|
||||
current = scrollIntent,
|
||||
deltaDp = -consumed.y / density.density,
|
||||
isUserInput = source == NestedScrollSource.UserInput,
|
||||
atNewest = !columnState.canScrollForward
|
||||
)
|
||||
return Offset.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(columnState) {
|
||||
var lastPosition = -1
|
||||
var scrollIntent = ChatScrollIntentState()
|
||||
snapshotFlow {
|
||||
val first = columnState.layoutInfo.visibleItems.firstOrNull()
|
||||
ChatScrollSnapshot(
|
||||
canScrollForward = columnState.canScrollForward,
|
||||
isScrollInProgress = columnState.isScrollInProgress,
|
||||
position = (first?.index ?: 0) * 100_000 + (first?.offset ?: 0)
|
||||
)
|
||||
}.collect { snapshot ->
|
||||
scrollIntent = updatedChatScrollIntent(
|
||||
current = scrollIntent,
|
||||
snapshot = snapshot,
|
||||
previousPosition = lastPosition
|
||||
)
|
||||
followNewest = scrollIntent.followsNewest
|
||||
controlsVisible.value = scrollIntent.controlsVisible
|
||||
lastPosition = snapshot.position
|
||||
!columnState.canScrollForward && !columnState.isScrollInProgress
|
||||
}.collect { atNewest ->
|
||||
if (atNewest) scrollIntent = ChatScrollIntentState()
|
||||
}
|
||||
}
|
||||
|
||||
// Stick to bottom when the user has not intentionally moved into history.
|
||||
LaunchedEffect(columnState, messages.size) {
|
||||
if (messages.isNotEmpty() && followNewest) {
|
||||
if (messages.isNotEmpty() && scrollIntent.followsNewest) {
|
||||
val expectedSingleMessageKey = messages.singleOrNull()?.id
|
||||
scrollToNewestAfterItemsMeasured(
|
||||
expectedItemCount = messages.size,
|
||||
@ -137,7 +146,14 @@ fun ChatScaffold(
|
||||
) {
|
||||
// scrollBy to the end of the range: animateScrollToItem stops as soon as the
|
||||
// item is partially visible, which left the last message cropped.
|
||||
columnState.scroll { scrollBy(Float.MAX_VALUE) }
|
||||
// Do not seize the list from an active drag/crown gesture, or follow an
|
||||
// append whose measurement completed after the user entered history.
|
||||
followNewestWhenIdle(
|
||||
scrolling = snapshotFlow { columnState.isScrollInProgress },
|
||||
shouldFollow = { scrollIntent.followsNewest }
|
||||
) {
|
||||
columnState.scroll { scrollBy(Float.MAX_VALUE) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -149,10 +165,10 @@ fun ChatScaffold(
|
||||
voice = voice,
|
||||
onOpenImage = onOpenImage,
|
||||
columnState = columnState,
|
||||
controlsVisible = controlsVisible.value,
|
||||
controlsVisible = scrollIntent.controlsVisible,
|
||||
header = header,
|
||||
actionBar = actionBar,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
modifier = Modifier.fillMaxSize().nestedScroll(scrollConnection)
|
||||
)
|
||||
}
|
||||
|
||||
@ -161,47 +177,30 @@ internal data class MeasuredChatLayout(
|
||||
val singleVisibleItemKey: Any?
|
||||
)
|
||||
|
||||
internal data class ChatScrollSnapshot(
|
||||
val canScrollForward: Boolean,
|
||||
val isScrollInProgress: Boolean,
|
||||
val position: Int
|
||||
)
|
||||
|
||||
internal data class ChatScrollIntentState(
|
||||
val followsNewest: Boolean = true,
|
||||
val controlsVisible: Boolean = true,
|
||||
val accumulatedDeltaPx: Int = 0
|
||||
val reversalDp: Float = 0f
|
||||
)
|
||||
|
||||
internal fun updatedChatScrollIntent(
|
||||
current: ChatScrollIntentState,
|
||||
snapshot: ChatScrollSnapshot,
|
||||
previousPosition: Int
|
||||
deltaDp: Float,
|
||||
isUserInput: Boolean,
|
||||
atNewest: Boolean
|
||||
): ChatScrollIntentState {
|
||||
if (!snapshot.canScrollForward) return ChatScrollIntentState()
|
||||
if (!snapshot.isScrollInProgress || previousPosition < 0) return current
|
||||
|
||||
val delta = snapshot.position - previousPosition
|
||||
val accumulatedDelta = when {
|
||||
delta == 0 -> current.accumulatedDeltaPx
|
||||
current.accumulatedDeltaPx == 0 ||
|
||||
current.accumulatedDeltaPx.sign == delta.sign ->
|
||||
current.accumulatedDeltaPx + delta
|
||||
else -> delta
|
||||
}
|
||||
val movedAway = accumulatedDelta <= -CHAT_SCROLL_DIRECTION_THRESHOLD_PX
|
||||
val movedTowardNewest = accumulatedDelta >= CHAT_SCROLL_DIRECTION_THRESHOLD_PX
|
||||
|
||||
if (atNewest) return ChatScrollIntentState()
|
||||
if (!isUserInput || !deltaDp.isFinite() || deltaDp == 0f) return current
|
||||
// Hysteresis measures net travel opposite the current controls state, not the sum of
|
||||
// tiny back-and-forth movements. Keep it across discrete crown ticks and idle periods.
|
||||
val reversal = (current.reversalDp + if (current.controlsVisible) -deltaDp else deltaDp)
|
||||
.coerceAtLeast(0f)
|
||||
val threshold = if (current.controlsVisible) 12f else 24f
|
||||
val toggle = reversal >= threshold
|
||||
return current.copy(
|
||||
followsNewest = current.followsNewest && !movedAway,
|
||||
controlsVisible = when {
|
||||
movedAway -> false
|
||||
movedTowardNewest -> true
|
||||
else -> current.controlsVisible
|
||||
},
|
||||
// Keep sub-threshold movement across discrete rotary events. Once intent is clear,
|
||||
// start a fresh accumulator so reversing direction gets the same threshold treatment.
|
||||
accumulatedDeltaPx = if (movedAway || movedTowardNewest) 0 else accumulatedDelta
|
||||
followsNewest = current.followsNewest && deltaDp >= 0f,
|
||||
controlsVisible = if (toggle) !current.controlsVisible else current.controlsVisible,
|
||||
reversalDp = if (toggle) 0f else reversal
|
||||
)
|
||||
}
|
||||
|
||||
@ -219,6 +218,15 @@ internal suspend fun scrollToNewestAfterItemsMeasured(
|
||||
scrollToEnd()
|
||||
}
|
||||
|
||||
internal suspend fun followNewestWhenIdle(
|
||||
scrolling: Flow<Boolean>,
|
||||
shouldFollow: () -> Boolean,
|
||||
scrollToEnd: suspend () -> Unit
|
||||
) {
|
||||
scrolling.first { !it }
|
||||
if (shouldFollow()) scrollToEnd()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatBody(
|
||||
messages: List<BitchatMessage>,
|
||||
@ -236,6 +244,9 @@ private fun ChatBody(
|
||||
val context = LocalContext.current
|
||||
val transformationSpec = rememberTransformationSpec()
|
||||
val isScreenRound = LocalConfiguration.current.isScreenRound
|
||||
val headerClearance = with(LocalDensity.current) {
|
||||
maxOf(40.dp, 24.dp + (14f * 1.3f).sp.toDp() / 2 + 6.dp)
|
||||
}
|
||||
// Slide-to-cancel: while recording, the finger's position is tracked globally; the
|
||||
// overlay's mic button reports its bounds and becomes the cancel target when the
|
||||
// finger hovers it (with generous slack so the snap engages on approach).
|
||||
@ -362,7 +373,7 @@ private fun ChatBody(
|
||||
// duplicated padding and a shortened list viewport.
|
||||
contentPadding = scaffoldPadding.withVerticalClearance(
|
||||
layoutDirection = layoutDirection,
|
||||
top = CHAT_HEADER_CONTENT_CLEARANCE,
|
||||
top = headerClearance,
|
||||
bottom = CHAT_ACTION_BAR_CLEARANCE
|
||||
)
|
||||
) {
|
||||
@ -437,8 +448,6 @@ private fun ChatBody(
|
||||
// Extra finger slack (px, ~28dp at watch density) around the cancel target so the snap
|
||||
// engages as the finger approaches, not only on exact contact.
|
||||
private const val CANCEL_HOVER_SLANT_PX = 56f
|
||||
private const val CHAT_SCROLL_DIRECTION_THRESHOLD_PX = 24
|
||||
private val CHAT_HEADER_CONTENT_CLEARANCE = 56.dp
|
||||
private val CHAT_ACTION_BAR_CLEARANCE = 64.dp
|
||||
private val CHAT_HEADER_EDGE_FADE = 36.dp
|
||||
private val CHAT_ACTION_BAR_EDGE_FADE = 72.dp
|
||||
|
||||
@ -127,13 +127,13 @@ private fun ChatHeader(
|
||||
)
|
||||
val iconSize by
|
||||
androidx.compose.animation.core.animateDpAsState(
|
||||
targetValue = if (expanded) 16.dp else 11.dp,
|
||||
targetValue = if (expanded) 14.dp else 12.dp,
|
||||
animationSpec = spec,
|
||||
label = "hdrIcon",
|
||||
)
|
||||
val titleSize by
|
||||
androidx.compose.animation.core.animateFloatAsState(
|
||||
targetValue = if (expanded) 15f else 12f,
|
||||
targetValue = if (expanded) 14f else 12f,
|
||||
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
|
||||
label = "hdrTitle",
|
||||
)
|
||||
@ -156,7 +156,7 @@ private fun ChatHeader(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false).padding(end = 8.dp),
|
||||
modifier = Modifier.weight(1f, fill = false).padding(end = 2.dp),
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
|
||||
@ -150,13 +150,13 @@ private fun DmHeader(
|
||||
)
|
||||
val headerIconSize by
|
||||
androidx.compose.animation.core.animateDpAsState(
|
||||
targetValue = if (expanded) 16.dp else 11.dp,
|
||||
targetValue = if (expanded) 14.dp else 12.dp,
|
||||
animationSpec = spec,
|
||||
label = "dmHdrIcon",
|
||||
)
|
||||
val headerTitleSize by
|
||||
androidx.compose.animation.core.animateFloatAsState(
|
||||
targetValue = if (expanded) 15f else 12f,
|
||||
targetValue = if (expanded) 14f else 12f,
|
||||
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
|
||||
label = "dmHdrTitle",
|
||||
)
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
package com.bitchat.watch.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
@ -8,11 +7,11 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
@ -33,16 +32,22 @@ internal fun WearChatHeader(
|
||||
val configuration = LocalConfiguration.current
|
||||
val lineHeight = with(LocalDensity.current) { (fontSize * 1.3f).sp.toDp() }
|
||||
val rowHeight = maxOf(48.dp, lineHeight)
|
||||
val top = 12.dp + (rowHeight - lineHeight) / 2
|
||||
val top = (rowHeight - lineHeight) / 2
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
// Follow the existing title animation without animating layout or the hit target.
|
||||
val expansion = ((fontSize - 12f) / 2f).coerceIn(0f, 1f)
|
||||
val fadeEnd = maxOf((36f + 8f * expansion).dp, top + lineHeight + 4.dp)
|
||||
BoxWithConstraints(
|
||||
Modifier.fillMaxWidth()
|
||||
.background(
|
||||
Modifier.fillMaxWidth().drawWithCache {
|
||||
val brush =
|
||||
Brush.verticalGradient(
|
||||
0f to MaterialTheme.colorScheme.background,
|
||||
0.75f to MaterialTheme.colorScheme.background,
|
||||
0f to background,
|
||||
0.65f to background.copy(alpha = 0.95f),
|
||||
1f to Color.Transparent,
|
||||
endY = fadeEnd.toPx(),
|
||||
)
|
||||
),
|
||||
onDrawBehind { drawRect(brush) }
|
||||
},
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
val safeWidth =
|
||||
@ -59,8 +64,7 @@ internal fun WearChatHeader(
|
||||
}
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.padding(top = 12.dp)
|
||||
.width(safeWidth.coerceAtLeast(0.dp))
|
||||
Modifier.width(safeWidth.coerceAtLeast(48.dp))
|
||||
.height(rowHeight)
|
||||
.clickable(role = Role.Button, onClickLabel = onClickLabel, onClick = onClick),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
|
||||
@ -11,99 +11,131 @@ import org.junit.Test
|
||||
class ChatAutoScrollTest {
|
||||
|
||||
@Test
|
||||
fun `new scroll range from appended message keeps follow intent`() {
|
||||
val updated = updatedChatScrollIntent(
|
||||
current = ChatScrollIntentState(),
|
||||
snapshot = ChatScrollSnapshot(
|
||||
canScrollForward = true,
|
||||
isScrollInProgress = false,
|
||||
position = 100
|
||||
),
|
||||
previousPosition = 100
|
||||
)
|
||||
|
||||
assertEquals(ChatScrollIntentState(), updated)
|
||||
fun `append waits for active gesture and rechecks history intent`() = runTest {
|
||||
val scrolling = MutableStateFlow(true)
|
||||
var followsNewest = true
|
||||
var scrollCount = 0
|
||||
val job =
|
||||
launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
followNewestWhenIdle(scrolling, { followsNewest }) { scrollCount++ }
|
||||
}
|
||||
assertFalse(job.isCompleted)
|
||||
assertEquals(0, scrollCount)
|
||||
followsNewest = false
|
||||
scrolling.value = false
|
||||
job.join()
|
||||
assertEquals(0, scrollCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `user scroll away disables follow until list reaches newest again`() {
|
||||
val browsingHistory = updatedChatScrollIntent(
|
||||
current = ChatScrollIntentState(),
|
||||
snapshot = ChatScrollSnapshot(
|
||||
canScrollForward = true,
|
||||
isScrollInProgress = true,
|
||||
position = 60
|
||||
),
|
||||
previousPosition = 100
|
||||
)
|
||||
assertFalse(browsingHistory.followsNewest)
|
||||
assertFalse(browsingHistory.controlsVisible)
|
||||
fun `append follows after gesture settles when user remains at newest`() = runTest {
|
||||
val scrolling = MutableStateFlow(true)
|
||||
var scrollCount = 0
|
||||
val job =
|
||||
launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
followNewestWhenIdle(scrolling, { true }) { scrollCount++ }
|
||||
}
|
||||
assertEquals(0, scrollCount)
|
||||
scrolling.value = false
|
||||
job.join()
|
||||
assertEquals(1, scrollCount)
|
||||
}
|
||||
|
||||
val dockedAgain = updatedChatScrollIntent(
|
||||
current = browsingHistory,
|
||||
snapshot = ChatScrollSnapshot(
|
||||
canScrollForward = false,
|
||||
isScrollInProgress = false,
|
||||
position = 200
|
||||
),
|
||||
previousPosition = 60
|
||||
)
|
||||
assertEquals(ChatScrollIntentState(), dockedAgain)
|
||||
private fun move(
|
||||
state: ChatScrollIntentState,
|
||||
dp: Float,
|
||||
user: Boolean = true,
|
||||
newest: Boolean = false,
|
||||
) = updatedChatScrollIntent(state, dp, user, newest)
|
||||
|
||||
@Test
|
||||
fun `append and programmatic movement never change intent`() {
|
||||
val docked = ChatScrollIntentState()
|
||||
assertEquals(docked, move(docked, 1000f, user = false))
|
||||
val history = ChatScrollIntentState(false, false)
|
||||
assertEquals(history, move(history, 1000f, user = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `slow scroll away accumulates intent across sub-threshold updates`() {
|
||||
var state = ChatScrollIntentState()
|
||||
var previousPosition = 100
|
||||
|
||||
listOf(94, 88, 82, 76).forEach { position ->
|
||||
state = updatedChatScrollIntent(
|
||||
current = state,
|
||||
snapshot = ChatScrollSnapshot(
|
||||
canScrollForward = true,
|
||||
isScrollInProgress = true,
|
||||
position = position
|
||||
),
|
||||
previousPosition = previousPosition
|
||||
)
|
||||
previousPosition = position
|
||||
state = updatedChatScrollIntent(
|
||||
current = state,
|
||||
snapshot = ChatScrollSnapshot(
|
||||
canScrollForward = true,
|
||||
isScrollInProgress = false,
|
||||
position = position
|
||||
),
|
||||
previousPosition = previousPosition
|
||||
)
|
||||
}
|
||||
|
||||
assertFalse(state.followsNewest)
|
||||
assertFalse(state.controlsVisible)
|
||||
assertEquals(0, state.accumulatedDeltaPx)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `slow scroll toward newest reveals controls without restoring follow early`() {
|
||||
var state = ChatScrollIntentState(followsNewest = false, controlsVisible = false)
|
||||
var previousPosition = 60
|
||||
|
||||
listOf(66, 72, 78, 84).forEach { position ->
|
||||
state = updatedChatScrollIntent(
|
||||
current = state,
|
||||
snapshot = ChatScrollSnapshot(
|
||||
canScrollForward = true,
|
||||
isScrollInProgress = true,
|
||||
position = position
|
||||
),
|
||||
previousPosition = previousPosition
|
||||
)
|
||||
previousPosition = position
|
||||
}
|
||||
|
||||
fun `first consumed movement away suspends following before controls hide`() {
|
||||
val state = move(ChatScrollIntentState(), -1f)
|
||||
assertFalse(state.followsNewest)
|
||||
assertEquals(true, state.controlsVisible)
|
||||
assertEquals(0, state.accumulatedDeltaPx)
|
||||
assertFalse(move(state, -11f).controlsVisible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deliberate reversal reveals controls but does not resume following`() {
|
||||
val history = move(ChatScrollIntentState(), -12f)
|
||||
val almost = move(history, 23f)
|
||||
assertFalse(almost.controlsVisible)
|
||||
val revealed = move(almost, 1f)
|
||||
assertEquals(true, revealed.controlsVisible)
|
||||
assertFalse(revealed.followsNewest)
|
||||
assertEquals(ChatScrollIntentState(), move(revealed, 1f, newest = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `jitter does not accumulate into repeated toggles`() {
|
||||
var state = move(ChatScrollIntentState(), -12f)
|
||||
repeat(100) {
|
||||
state = move(state, 3f)
|
||||
state = move(state, -3f)
|
||||
}
|
||||
assertFalse(state.controlsVisible)
|
||||
assertEquals(0f, state.reversalDp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pauses and discrete crown ticks retain net movement`() {
|
||||
var state = ChatScrollIntentState()
|
||||
repeat(4) {
|
||||
state = move(state, -3f)
|
||||
state = move(state, 0f, user = false)
|
||||
}
|
||||
assertFalse(state.controlsVisible)
|
||||
repeat(8) {
|
||||
state = move(state, 3f)
|
||||
state = move(state, 0f, user = false)
|
||||
}
|
||||
assertEquals(true, state.controlsVisible)
|
||||
assertFalse(state.followsNewest)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fling preserves controls until newest is reached`() {
|
||||
val history = move(ChatScrollIntentState(), -12f)
|
||||
assertEquals(history, move(history, 1000f, user = false))
|
||||
assertEquals(history, move(history, -1000f, user = false))
|
||||
assertEquals(ChatScrollIntentState(), move(history, 1f, user = false, newest = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `consumed distance is independent of item boundaries and event chunking`() {
|
||||
val initial = ChatScrollIntentState()
|
||||
val oneEvent = move(initial, -12f)
|
||||
val acrossRows =
|
||||
listOf(-2f, -3f, -1f, -6f).fold(initial) { state, delta ->
|
||||
move(state, delta)
|
||||
}
|
||||
assertEquals(oneEvent, acrossRows)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pixel distances normalize to the same dp thresholds`() {
|
||||
for (density in listOf(1f, 1.6875f, 2f, 3f)) {
|
||||
val history = move(ChatScrollIntentState(), (-12f * density) / density)
|
||||
assertFalse(history.controlsVisible)
|
||||
assertEquals(true, move(history, (24f * density) / density).controlsVisible)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid and unconsumed input is ignored`() {
|
||||
val initial = ChatScrollIntentState()
|
||||
assertEquals(initial, move(initial, Float.NaN))
|
||||
assertEquals(initial, move(initial, Float.POSITIVE_INFINITY))
|
||||
assertEquals(initial, move(initial, 0f))
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -111,15 +143,16 @@ class ChatAutoScrollTest {
|
||||
val measuredLayouts = MutableStateFlow(MeasuredChatLayout(3, null))
|
||||
var scrollCount = 0
|
||||
|
||||
val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
scrollToNewestAfterItemsMeasured(
|
||||
expectedItemCount = 4,
|
||||
expectedSingleMessageKey = null,
|
||||
measuredLayouts = measuredLayouts
|
||||
) {
|
||||
scrollCount += 1
|
||||
val scrollJob =
|
||||
launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
scrollToNewestAfterItemsMeasured(
|
||||
expectedItemCount = 4,
|
||||
expectedSingleMessageKey = null,
|
||||
measuredLayouts = measuredLayouts,
|
||||
) {
|
||||
scrollCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertFalse(scrollJob.isCompleted)
|
||||
assertEquals(0, scrollCount)
|
||||
@ -138,7 +171,7 @@ class ChatAutoScrollTest {
|
||||
scrollToNewestAfterItemsMeasured(
|
||||
expectedItemCount = 4,
|
||||
expectedSingleMessageKey = null,
|
||||
measuredLayouts = measuredLayouts
|
||||
measuredLayouts = measuredLayouts,
|
||||
) {
|
||||
scrollCount += 1
|
||||
}
|
||||
@ -149,28 +182,31 @@ class ChatAutoScrollTest {
|
||||
@Test
|
||||
fun `first message waits past stale empty placeholder layout`() = runTest {
|
||||
val messageKey = "first-message"
|
||||
val measuredLayouts = MutableStateFlow(
|
||||
MeasuredChatLayout(itemCount = 1, singleVisibleItemKey = "empty-placeholder")
|
||||
)
|
||||
val measuredLayouts =
|
||||
MutableStateFlow(
|
||||
MeasuredChatLayout(itemCount = 1, singleVisibleItemKey = "empty-placeholder")
|
||||
)
|
||||
var scrollCount = 0
|
||||
|
||||
val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
scrollToNewestAfterItemsMeasured(
|
||||
expectedItemCount = 1,
|
||||
expectedSingleMessageKey = messageKey,
|
||||
measuredLayouts = measuredLayouts
|
||||
) {
|
||||
scrollCount += 1
|
||||
val scrollJob =
|
||||
launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
scrollToNewestAfterItemsMeasured(
|
||||
expectedItemCount = 1,
|
||||
expectedSingleMessageKey = messageKey,
|
||||
measuredLayouts = measuredLayouts,
|
||||
) {
|
||||
scrollCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertFalse(scrollJob.isCompleted)
|
||||
assertEquals(0, scrollCount)
|
||||
|
||||
measuredLayouts.value = MeasuredChatLayout(
|
||||
itemCount = 1,
|
||||
singleVisibleItemKey = messageKey
|
||||
)
|
||||
measuredLayouts.value =
|
||||
MeasuredChatLayout(
|
||||
itemCount = 1,
|
||||
singleVisibleItemKey = messageKey,
|
||||
)
|
||||
scrollJob.join()
|
||||
|
||||
assertEquals(1, scrollCount)
|
||||
|
||||
@ -11,12 +11,16 @@ class WearDisplayGeometryTest {
|
||||
fun `header band corners stay inside the circle at supported text sizes`() {
|
||||
for (diameter in listOf(192f, 228f, 240f)) {
|
||||
for (scale in listOf(0.94f, 1f, 1.24f, 1.3f)) {
|
||||
val lineHeight = 15f * 1.3f * scale
|
||||
val top = 12f + (maxOf(48f, lineHeight) - lineHeight) / 2f
|
||||
val width = roundBandWidth(diameter, diameter, top, top + lineHeight)
|
||||
val radius = diameter / 2f
|
||||
for (y in listOf(top, top + lineHeight)) {
|
||||
assertTrue((width / 2).pow(2) + (y - radius).pow(2) <= radius.pow(2) + 0.01f)
|
||||
for (titleSize in listOf(12f, 13f, 14f)) {
|
||||
val lineHeight = titleSize * 1.3f * scale
|
||||
val top = (maxOf(48f, lineHeight) - lineHeight) / 2f
|
||||
val width = roundBandWidth(diameter, diameter, top, top + lineHeight)
|
||||
val radius = diameter / 2f
|
||||
for (y in listOf(top, top + lineHeight)) {
|
||||
assertTrue(
|
||||
(width / 2).pow(2) + (y - radius).pow(2) <= radius.pow(2) + 0.01f
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user