From 2572da00fde67194019308e137d4f75bb3e3f65f Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 01:56:06 +0100 Subject: [PATCH] Polish Requests transitions and shell hosts --- .../wolfskin/design/ComingInSheet.kt | 173 ++++++++-- .../wolfskin/design/ShellState.kt | 10 +- .../wolfskin/design/ShellSurface.kt | 8 +- .../wolfskin/design/RequestsKeysTest.kt | 4 +- .../wolfskin/design/RequestsPolishTest.kt | 310 ++++++++++++++++++ 5 files changed, 466 insertions(+), 39 deletions(-) create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsPolishTest.kt diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt index 66d94daa..619f7ac8 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt @@ -1,14 +1,24 @@ // SPDX-License-Identifier: GPL-2.0-only package org.justinvisser.wolfskin.design +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -16,6 +26,13 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.layout.layout +import kotlin.math.roundToInt import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb @@ -29,6 +46,7 @@ import androidx.tv.material3.Text import coil3.compose.AsyncImage import coil3.compose.AsyncImagePainter import androidx.compose.runtime.mutableStateOf +import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T @@ -36,10 +54,19 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T /** The overlay intercepts pointer input as well as the shell's virtual D-pad focus. */ @Composable fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { + val visible = shell.requestsVisible + val fade = rememberSheetFade(visible) + if (!visible && fade.value == 0f) return val state = shell.requests - val data = state.snapshot + val current = RequestsPanelFrame(state.snapshot, state.focusedId, state.expanded, state.windowStart, state.windowSize, + state.recoveryFocused, state.ratingsVisible, shell.requestsExpansionInput) + val retained = remember { mutableStateOf(current) } + SideEffect { if (visible) retained.value = current } + val frame = if (visible) current else retained.value + val data = frame.data val ratingsReader = LocalScreenRatings.current - LaunchedEffect(state, state.focusedId, data.signedIn, data.status) { + LaunchedEffect(visible, state, state.focusedId, data.signedIn, data.status) { + if (!visible) return@LaunchedEffect if (ratingsReader == null) state.awaitFocusedRatings() else if (data.signedIn) state.focused?.let { item -> ratingsReader.read(item.tmdbId, item.mediaType, null, null, cachedOnly = data.status == ComingInStatus.Unavailable)?.let { values -> @@ -50,8 +77,9 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { } } } - Box(modifier.fillMaxSize().background(C.sheetScrim).clickable { shell.closeRequests() }.testTag("coming-in-scrim")) - Column(modifier.fillMaxHeight().width(L.sheetWidth) + Box(modifier.fillMaxSize().sheetScrim { fade.value }.focusProperties { canFocus = false } + .clickable { if (visible) shell.closeRequests() }.testTag("coming-in-scrim")) + Column(modifier.sheetPanelFade { fade.value }.fillMaxHeight().width(L.sheetWidth) .offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth) .drawWithCache { val panel = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { @@ -61,9 +89,11 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { onDrawBehind { drawIntoCanvas { it.nativeCanvas.drawRect(0f, 0f, size.width, size.height, panel) } } - }.pointerInput(Unit) { + }.focusProperties { canFocus = visible } + .then(if (visible) Modifier else Modifier.clearAndSetSemantics { }) + .pointerInput(visible) { awaitPointerEventScope { - while (true) awaitPointerEvent(PointerEventPass.Final).changes.forEach { it.consume() } + while (true) awaitPointerEvent(if (visible) PointerEventPass.Final else PointerEventPass.Initial).changes.forEach { it.consume() } } } .padding(horizontal = T.sheetPaddingX, vertical = T.sheetPaddingY) @@ -71,10 +101,10 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { Text("Coming in", Modifier.comingInLineBox(T.sheetTitleLineHeight, T.sheetTitleBaseline), style = ComingInTypography.title, color = C.fg) when { data.status == ComingInStatus.Loading && data.items.isEmpty() -> { - LazyColumn(verticalArrangement = Arrangement.spacedBy(T.listGap)) { + LazyColumn(Modifier.skeletonPulse(), verticalArrangement = Arrangement.spacedBy(T.listGap)) { items(3, key = { "request-skeleton-$it" }, contentType = { "skeleton" }) { ComingInSkeleton() } } - SheetButton("Close") { shell.closeRequests() } + SheetButton("Close", interactive = visible) { shell.closeRequests() } } data.status == ComingInStatus.Unavailable && data.items.isEmpty() -> { Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)) @@ -86,34 +116,50 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { } Text("Conductarr didn’t answer.", style = ComingInTypography.body, color = C.muted) } - SheetButton("Try again", primary = true) { shell.retryRequests() } + SheetButton("Try again", primary = true, interactive = visible) { shell.retryRequests() } + } + data.items.isEmpty() -> { + Column(Modifier.padding(top = T.emptyMargin), verticalArrangement = Arrangement.spacedBy(T.receiptGap)) { + Text("Nothing coming in.", style = ComingInTypography.body, color = C.muted) + Text("New requests show up here.", style = ComingInTypography.body, color = C.muted) + } + SheetButton("Close", interactive = visible) { shell.closeRequests() } } - data.items.isEmpty() -> Text("Nothing coming in.", Modifier.padding(top = T.emptyMargin), style = ComingInTypography.body, color = C.muted) else -> { if (data.status == ComingInStatus.Unavailable) Text("Conductarr didn’t answer.", style = ComingInTypography.caption, color = C.warn) data.savedAt?.takeIf { data.status == ComingInStatus.Unavailable }?.let { SavedPill(it) } if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn) - val start = state.windowStart - val visibleRows = remember(data, start, state.windowSize) { - data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size)) + val start = frame.start + val listState = rememberLazyListState() + val placement = rememberRequestPlacement(frame, listState) + val visibleRows = remember(data, start, frame.windowSize) { + data.ordered.subList(start, (start + frame.windowSize).coerceAtMost(data.ordered.size)) } - LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier), verticalArrangement = Arrangement.spacedBy(T.listGap)) { + LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier), + state = listState, verticalArrangement = Arrangement.spacedBy(T.listGap)) { val keys = visibleRows.map { it.id }.uniqueKeys() - itemsIndexed(visibleRows, key = { index, _ -> keys[index] }, contentType = { _, _ -> "request" }) { _, item -> - ComingInRow(item, shell.person, !state.recoveryFocused && item.id == state.focusedId, state.expanded && item.id == state.focusedId, - state.ratingsVisible && data.signedIn && item.id == state.focusedId, - onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() }) + itemsIndexed(visibleRows, key = { index, _ -> keys[index] }, contentType = { _, _ -> "request" }) { index, item -> + ComingInRow(item, shell.person, !frame.recoveryFocused && item.id == frame.focusedId, frame.expanded && item.id == frame.focusedId, + frame.ratingsVisible && data.signedIn && item.id == frame.focusedId, + onFocus = { if (visible) state.focus(item.id) }, onClick = { if (visible) { state.focus(item.id); shell.activateRequest() } }, + modifier = Modifier.layout { measurable, constraints -> + val row = measurable.measure(constraints) + layout(row.width, row.height) { + row.placeRelative(0, placement.offset(keys[index], listState.layoutInfo)) + } + }, + readinessProgress = { placement.progress.value }, interactive = visible) } } - if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = state.recoveryFocused) { shell.retryRequests() } + if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = frame.recoveryFocused, interactive = visible) { shell.retryRequests() } } } } } @Composable -private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, onClick: () -> Unit) { - Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = onClick) } +private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, interactive: Boolean = true, onClick: () -> Unit) { + Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = { if (interactive) onClick() }) } } @Composable @@ -128,32 +174,95 @@ private fun ComingInSkeleton() { } } +private data class RequestsPanelFrame(val data: ComingInSnapshot, val focusedId: String?, val expanded: Boolean, + val start: Int, val windowSize: Int, val recoveryFocused: Boolean, val ratingsVisible: Boolean, val expansionInput: Long) + +private class RequestPlacementMemory(var frame: RequestsPanelFrame, var motion: RequestsPlacement? = null) + +/** Placement reads one clock; target-offset caching happens only when the list is measured. */ +private class RequestsPlacement(val animate: Boolean, private val origins: Map) { + val progress: Animatable = Animatable(if (animate) 0f else 1f) + private var measured: LazyListLayoutInfo? = null + private var targets: Map = emptyMap() + + fun offset(key: Any, info: LazyListLayoutInfo): Int { + val progress = progress.value + if (!animate || progress == 1f) return 0 + if (measured !== info) { + targets = info.visibleItemsInfo.associate { it.key to it.offset } + measured = info + } + val target = targets[key] ?: return 0 + return (((origins[key] ?: target) - target) * (1f - progress)).roundToInt() + } +} + +/** Only remote expansion toggles animate; interruptions start at the current visual placement. */ +@Composable +private fun rememberRequestPlacement(frame: RequestsPanelFrame, listState: LazyListState): RequestsPlacement { + val previous = remember { RequestPlacementMemory(frame) } + val reduced = reducedMotion() + val motion = remember(frame, reduced) { + val animate = !reduced && previous.frame.let { + it.expansionInput != frame.expansionInput && it.data === frame.data && it.focusedId == frame.focusedId && + it.start == frame.start && it.expanded != frame.expanded + } + val info = listState.layoutInfo + val origins = if (animate) info.visibleItemsInfo.associate { + it.key to it.offset + (previous.motion?.offset(it.key, info) ?: 0) + } else emptyMap() + RequestsPlacement(animate, origins) + } + SideEffect { previous.frame = frame; previous.motion = motion } + LaunchedEffect(motion) { + if (motion.animate) motion.progress.animateTo(1f, tween(M.lateLineFade, easing = M.easeOut)) + } + return motion +} + /** Thumbnail slots do not resize when the text or the readiness block grows. */ @OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class) @Composable fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, expanded: Boolean, - showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit) { + showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit, modifier: Modifier = Modifier, + readinessProgress: () -> Float = { 1f }, interactive: Boolean = true) { val foreground = if (focused) C.onFocus else C.fg val secondary = if (focused) C.onFocus else C.muted val context = LocalContext.current val request = remember(item.poster, context) { item.poster?.let { WolfskinImages.request(context, it, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt(), true) } } - val posterReady = remember(request) { mutableStateOf(false) } - Row(Modifier.fillMaxWidth().focusScale(focused, WolfskinTokens.Focus.scaleRow) + val posterState = remember(request) { mutableStateOf(AsyncImagePainter.State.Empty) } + var collapsedHeight by remember { mutableIntStateOf(0) } + Row(modifier.fillMaxWidth().testTag("coming-row-${item.id}") + .onSizeChanged { if (!expanded) collapsedHeight = it.height } + .drawWithContent { + val progress = readinessProgress() + if (expanded && collapsedHeight > 0 && progress < 1f) { + val bottom = collapsedHeight + (size.height - collapsedHeight) * progress + clipRect(left = -size.width, top = -size.height, right = size.width * 2, bottom = bottom) { + this@drawWithContent.drawContent() + } + } else drawContent() + }.focusScale(focused, WolfskinTokens.Focus.scaleRow) .background(if (focused) C.fg else Color.Transparent, RoundedCornerShape(WolfskinTokens.Radius.panel)) - .pointerInput(item.id) { + .then(if (interactive) Modifier.pointerInput(item.id) { awaitPointerEventScope { while (true) if (awaitPointerEvent().type == PointerEventType.Enter) onFocus() } - }.clickable(onClick = onClick) - .padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY).testTag("coming-row-${item.id}"), + } else Modifier).clickable(enabled = interactive, onClick = onClick) + .padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) { - AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null, - modifier = Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb)) - .background(if (posterReady.value) Color.Transparent else C.surface3), - onState = { posterReady.value = it is AsyncImagePainter.State.Success }, - contentScale = androidx.compose.ui.layout.ContentScale.Crop) + Box(Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb)) + .background(if (posterState.value is AsyncImagePainter.State.Success) Color.Transparent else C.surface3) + .testTag("coming-thumb-${item.id}"), contentAlignment = Alignment.Center) { + AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null, + modifier = Modifier.fillMaxSize(), onState = { posterState.value = it }, + contentScale = androidx.compose.ui.layout.ContentScale.Crop) + if (request == null || posterState.value is AsyncImagePainter.State.Error) { + Glyph(if (item.mediaType == "movie") "film" else "tv", C.dim, Modifier.size(T.thumbGlyph)) + } + } Column(Modifier.weight(1f)) { Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline), style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt index 3499479c..e875fbc2 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -82,6 +82,8 @@ class ShellState( var peopleStatus by mutableStateOf(PeopleStatus.Ready) var recoveryIndex by mutableStateOf(0) var requestsVisible by mutableStateOf(false) + internal var requestsExpansionInput = 0L + private set var toast by mutableStateOf(null) private val personRequests = mutableMapOf() val requests: ComingInState get() = personRequests.getValue(person.key) @@ -161,8 +163,12 @@ class ShellState( when { key == ShellKey.Back -> closeRequests() key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Unavailable && (requests.snapshot.items.isEmpty() || requests.recoveryFocused) -> retryRequests() - key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Loading && requests.snapshot.items.isEmpty() -> closeRequests() - requests.key(key) -> activateRequest() + key == ShellKey.Ok && requests.snapshot.items.isEmpty() && requests.snapshot.status != ComingInStatus.Unavailable -> closeRequests() + else -> { + val expanded = requests.expanded + if (requests.key(key)) activateRequest() + if ((key == ShellKey.Right || key == ShellKey.Left) && requests.expanded != expanded) requestsExpansionInput++ + } } return true } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt index c6dc38b1..47687caa 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt @@ -96,12 +96,14 @@ fun ShellSurface( if (state.pickerVisible) { WhosWatching(state) } else { - if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) + FocusRingHost { + if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) + } NavBar(state, view.listState.firstVisibleItemIndex > 0 || view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2) - if (state.requestsVisible) ComingInSheet(state) + ComingInSheet(state) overlay() } - state.toast?.let { RecoveryToast(it) } + RecoveryToastHost(state.toast) } } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt index cba88883..5dcd1baf 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt @@ -100,9 +100,9 @@ class RequestsKeysTest { assertEquals(1, retries) assertTrue(state.requestsVisible) rule.runOnIdle { state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready)) } + rule.onNodeWithText("New requests show up here.").assertExists() send(Key.DirectionDown, Key.DirectionCenter) - rule.onNodeWithText("Nothing coming in.").assertExists() - assertTrue(state.requestsVisible) + assertFalse(state.requestsVisible) } @Test fun cachedRatingsRenderInTheTelevisionLocale() { diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsPolishTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsPolishTest.kt new file mode 100644 index 00000000..3350674d --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsPolishTest.kt @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import coil3.intercept.Interceptor +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.awaitCancellation +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M + +/** Native frames exercise Requests transitions without replacing the shell's key or focus policy. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class RequestsPolishTest { + @get:Rule val rule = createAndroidComposeRule() + private val reduced = mutableStateOf(false) + + @Before fun fullscreen() { + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + rule.mainClock.autoAdvance = false + } + + private fun shell(snapshot: ComingInSnapshot = requestsFixture()) = ShellState(fixturePeople(), "justin").apply { + pickerVisible = false + pickerLaunch = false + enterDestination("settings") + settingsRow = 2 + requests.update(snapshot) + } + + private fun mount(state: ShellState) { + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { + WolfskinTheme { ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) } + } } + rule.mainClock.advanceTimeBy(32) + rule.waitForIdle() + } + + private fun change(block: () -> Unit) { + rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + } + + private fun capture(idea: String, name: String): Bitmap { + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/$idea").apply { mkdirs() } + val file = File(directory, "$name.png") + rule.onRoot().captureRoboImage(file.absolutePath) + return BitmapFactory.decodeFile(file.absolutePath) + } + + private fun csv(idea: String, rows: List) { + File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/$idea/samples.csv").writeText(rows.joinToString("\n", postfix = "\n")) + } + + private fun brightness(pixel: Int) = android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel) + private fun monotonic(values: List, increasing: Boolean) { + assertNotEquals("Transition must change the sampled value", values.first(), values.last()) + values.zipWithNext().forEach { (first, second) -> assertTrue("Non-monotonic samples: $values", if (increasing) second >= first - 1f else second <= first + 1f) } + assertTrue("Transition must include an intermediate frame: $values", values.any { it > minOf(values.first(), values.last()) + 1 && it < maxOf(values.first(), values.last()) - 1 }) + } + + @Test fun requestsPanelFadesWithItsScrim() { + val state = shell(ComingInSnapshot(status = ComingInStatus.Ready)) + mount(state) + val samples = mutableListOf("mode,phase,timeMs,scrim,panel") + for (reduce in listOf(false, true)) { + change { reduced.value = reduce; state.openRequests() } + val entering = List(13) { index -> + val image = capture("P06", "requests-${if (reduce) "reduced-" else ""}open-${index.toString().padStart(2, '0')}") + val pixels = brightness(image.getPixel(400, 900)).toFloat() to brightness(image.getPixel(1500, 900)).toFloat() + image.recycle() + samples += "$reduce,open,${index * 16},${pixels.first},${pixels.second}" + rule.mainClock.advanceTimeBy(16) + pixels + } + val row = state.settingsRow + val nav = state.navFocused + change { state.closeRequests() } + assertEquals(row, state.settingsRow) + assertEquals(nav, state.navFocused) + rule.onNodeWithTag("coming-in-scrim").performTouchInput { click(Offset(400f, 900f)) } + val leaving = List(9) { index -> + val image = capture("P06", "requests-${if (reduce) "reduced-" else ""}close-${index.toString().padStart(2, '0')}") + val pixels = brightness(image.getPixel(400, 900)).toFloat() to brightness(image.getPixel(1500, 900)).toFloat() + image.recycle() + samples += "$reduce,close,${index * 16},${pixels.first},${pixels.second}" + rule.mainClock.advanceTimeBy(16) + pixels + } + csv("P06", samples) + monotonic(entering.map { it.first }, false) + monotonic(entering.map { it.second }, true) + monotonic(leaving.map { it.first }, true) + monotonic(leaving.map { it.second }, false) + assertFalse(state.requestsVisible) + assertEquals(row, state.settingsRow) + assertEquals(nav, state.navFocused) + rule.onNodeWithTag("coming-in").assertDoesNotExist() + val end = if (reduce) 5 else 10 + assertEquals(entering.last(), entering[end + 1]) + assertEquals(leaving.last(), leaving[if (reduce) 6 else 8]) + } + } + + @Test fun loadingRowsBreatheAsOneGroupAndStopWhenLoaded() { + val state = shell(ComingInSnapshot(status = ComingInStatus.Loading)).apply { openRequests() } + mount(state) + rule.mainClock.advanceTimeBy(240) + val samples = mutableListOf("mode,timeMs,thumb1,thumb2,button") + for (reduce in listOf(false, true)) { + change { reduced.value = reduce } + val close = rule.onNodeWithText("Close").fetchSemanticsNode().boundsInRoot + val times = ((0..1400 step 100).toList() + 350).sorted() + val values = times.mapIndexed { index, time -> + val image = capture("P07", "requests-loading-${if (reduce) "reduced-" else ""}${time.toString().padStart(4, '0')}") + val pixels = listOf(image.getPixel(1240, 240), image.getPixel(1240, 418), image.getPixel(close.center.x.toInt(), close.bottom.toInt() + 8)) + image.recycle() + samples += "$reduce,$time,${pixels.joinToString(",")}" + if (index < times.lastIndex) rule.mainClock.advanceTimeBy((times[index + 1] - time).toLong(), ignoreFrameDuration = true) + pixels + } + csv("P07", samples) + assertEquals(values.map { it[0] }, values.map { it[1] }) + assertEquals(1, values.map { it[2] }.distinct().size) + if (reduce) assertEquals(1, values.map { it[0] }.distinct().size) + else { + assertNotEquals(values[0][0], values[4][0]) + assertNotEquals(values[4][0], values[8][0]) + } + } + change { reduced.value = false; state.requests.update(requestsFixture()) } + rule.mainClock.advanceTimeBy(400) + rule.onNodeWithText("Close").assertDoesNotExist() + assertTrue("Loaded panel must leave the recomposer idle", androidx.compose.runtime.Recomposer.runningRecomposers.value.all { !it.hasPendingWork }) + } + + @Test fun readinessExpansionPushesRowsBelowWithoutRemeasure() { + val state = shell().apply { openRequests(); requests.key(ShellKey.Down) } + mount(state) + // Rating dwell changes layout independently; toggles require settled metadata. + rule.mainClock.advanceTimeBy(M.ratingDwell.toLong()) + val samples = mutableListOf("mode,phase,timeMs,focusedTop,followingTop") + for (reduce in listOf(false, true)) { + change { reduced.value = reduce } + val focusedBounds = rule.onNodeWithTag("coming-row-x1").fetchSemanticsNode().boundsInRoot + fun focusedTop(): Float { + val bounds = rule.onNodeWithTag("coming-row-x1").fetchSemanticsNode().boundsInRoot + val scale = WolfskinTokens.Focus.scaleRow + val scaleOffset = kotlin.math.abs(bounds.height - focusedBounds.height) * (scale - 1f) / (2f * scale) + assertEquals(focusedBounds.top, bounds.top, scaleOffset + 0.5f) + return bounds.top + } + val collapsed = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top + change { state.key(ShellKey.Right) } + val expanding = List(16) { index -> + val top = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top + val fixed = focusedTop() + capture("P09", "expand-${if (reduce) "reduced-" else ""}${index.toString().padStart(2, '0')}").recycle() + samples += "$reduce,expand,${index * 16},$fixed,$top" + rule.mainClock.advanceTimeBy(16) + top + } + change { state.key(ShellKey.Left) } + val collapsing = List(16) { index -> + val top = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top + capture("P09", "collapse-${if (reduce) "reduced-" else ""}${index.toString().padStart(2, '0')}").recycle() + samples += "$reduce,collapse,${index * 16},${focusedTop()},$top" + rule.mainClock.advanceTimeBy(16) + top + } + csv("P09", samples) + assertEquals(collapsed, collapsing.last(), 0.1f) + if (reduce) { + assertTrue(expanding.all { kotlin.math.abs(it - expanding.last()) < 0.1f }) + assertTrue(collapsing.all { kotlin.math.abs(it - collapsed) < 0.1f }) + } else { + monotonic(listOf(collapsed) + expanding, true) + assertTrue("Expansion must not finish early", expanding[5] < expanding.last() - 1f) + monotonic(listOf(expanding.last()) + collapsing, false) + assertTrue("Collapse must not finish early", collapsing[5] > collapsing.last() + 1f) + } + } + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/P09") + for (phase in listOf("expand", "collapse")) { + val animated = BitmapFactory.decodeFile(File(directory, "$phase-15.png").absolutePath) + val snapped = BitmapFactory.decodeFile(File(directory, "$phase-reduced-15.png").absolutePath) + try { assertTrue("Settled $phase pixels must match reduced-motion snapping", animated.sameAs(snapped)) } + finally { animated.recycle(); snapped.recycle() } + } + val collapsedTop = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top + change { reduced.value = false; state.key(ShellKey.Right) } + rule.mainClock.advanceTimeBy(48) + change { state.key(ShellKey.Left) } + val retargeted = List(16) { index -> + val top = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top + capture("P09", "retarget-${index.toString().padStart(2, '0')}").recycle() + samples += "false,retarget,${index * 16},${rule.onNodeWithTag("coming-row-x1").fetchSemanticsNode().boundsInRoot.top},$top" + rule.mainClock.advanceTimeBy(16) + top + } + assertTrue("An interrupted expand must return from its current placement", retargeted.first() > collapsedTop + 1f) + monotonic(retargeted, false) + assertEquals(collapsedTop, retargeted.last(), 0.1f) + change { reduced.value = false; state.key(ShellKey.Right) } + rule.mainClock.advanceTimeBy(48) + change { state.key(ShellKey.Down) } + val jumped = rule.onNodeWithTag("coming-row-x3").fetchSemanticsNode().boundsInRoot.top + rule.mainClock.advanceTimeBy(240) + assertEquals(jumped, rule.onNodeWithTag("coming-row-x3").fetchSemanticsNode().boundsInRoot.top, 0.1f) + rule.mainClock.advanceTimeBy(M.ratingDwell.toLong()) + change { state.key(ShellKey.Right) } + rule.mainClock.advanceTimeBy(48) + change { state.requests.update(state.requests.snapshot.copy(partial = true)) } + val refreshed = rule.onNodeWithTag("coming-row-x3").fetchSemanticsNode().boundsInRoot.top + rule.mainClock.advanceTimeBy(240) + assertEquals(refreshed, rule.onNodeWithTag("coming-row-x3").fetchSemanticsNode().boundsInRoot.top, 0.1f) + csv("P09", samples) + } + + @Test fun emptyRequestsCloseReturnsToSettingsRow() { + val state = shell(ComingInSnapshot(status = ComingInStatus.Ready)).apply { openRequests() } + mount(state) + rule.mainClock.advanceTimeBy(240) + capture("P10", "requests-empty").recycle() + rule.onNodeWithText("New requests show up here.").assertExists() + val row = state.settingsRow + val nav = state.navFocused + change { state.key(ShellKey.Down); state.key(ShellKey.Ok) } + assertFalse(state.requestsVisible) + assertEquals(row, state.settingsRow) + assertEquals(nav, state.navFocused) + rule.mainClock.advanceTimeBy(160) + change { state.openRequests() } + rule.mainClock.advanceTimeBy(240) + rule.onNodeWithText("Close").performClick() + assertFalse(state.requestsVisible) + assertEquals(row, state.settingsRow) + assertEquals(nav, state.navFocused) + } + + @Test fun missingPosterShowsFilmOrTvGlyph() { + val completed = AtomicBoolean(false) + WolfskinImages.configureNetwork(rule.activity) { + add(Interceptor { chain -> + if (chain.request.data == "pending-poster") awaitCancellation() + else chain.proceed().also { if (chain.request.data == "file:///missing-wolfskin-poster.png") completed.set(true) } + }) + } + val source = requestsFixture().items.first() + val items = listOf(source.copy(id = "movie", mediaType = "movie", poster = null), + source.copy(id = "tv", mediaType = "tv", poster = null), + source.copy(id = "failed", mediaType = "movie", poster = "file:///missing-wolfskin-poster.png"), + source.copy(id = "pending", poster = "pending-poster")) + val state = shell(ComingInSnapshot(items, ComingInStatus.Ready)).apply { openRequests(); requests.focus("pending") } + mount(state) + rule.waitUntil(5000) { completed.get() } + rule.mainClock.advanceTimeBy(400) + val image = capture("P12", "missing-thumbs") + try { + fun glyph(id: String): List { + val thumb = rule.onNodeWithTag("coming-thumb-$id", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val left = (thumb.center.x - 20).toInt() + val top = (thumb.center.y - 20).toInt() + return (0 until 40).flatMap { y -> (0 until 40).map { x -> image.getPixel(left + x, top + y) } } + } + val movie = glyph("movie") + val tv = glyph("tv") + val failed = glyph("failed") + val stroke = C.dim.compositeOver(C.surface3).toArgb() + listOf(movie, tv, failed).forEach { pixels -> + assertTrue("The thumbnail centre column must contain the dim glyph stroke", (0 until 40).any { y -> pixels[y * 40 + 20] == stroke }) + } + assertNotEquals(movie, tv) + val deltas = movie.zip(failed).map { (first, second) -> + listOf(16, 8, 0).map { shift -> kotlin.math.abs(((first ushr shift) and 255) - ((second ushr shift) and 255)) } + } + assertTrue("Absent and failed posters must render the same film glyph", deltas.flatten().average() < 2) + assertTrue("Only antialiasing differences are allowed", deltas.count { it.max() <= 8 } >= movie.size * 0.99) + assertTrue("Loading remains a plain thumbnail", glyph("pending").all { it == C.surface3.toArgb() }) + } finally { image.recycle(); WolfskinImages.configureNetwork(rule.activity) {} } + } +}