diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt index df050bc3..636d5682 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt @@ -1,6 +1,8 @@ // SPDX-License-Identifier: GPL-2.0-only package org.justinvisser.wolfskin.design +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -18,12 +20,23 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape 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.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.platform.testTag @@ -37,48 +50,67 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L /** Sheets draw after the shell nav: their scrim consumes pointers and focus stays inside. */ @Composable fun DiscoverOverlay(state: DiscoverState) { - val overlay = state.overlay - if (overlay != null && !state.shell.pickerVisible) { - Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable(onClick = {}).testTag("discover-overlay")) { + val current = state.overlay + var retained by remember { mutableStateOf(current) } + SideEffect { if (current != null) retained = current } + val visible = current != null && !state.shell.pickerVisible + val fade = rememberSheetFade(visible) + val overlay = current ?: retained + if (overlay != null && !state.shell.pickerVisible && (visible || fade.value > 0f)) { + Box(Modifier.fillMaxSize().sheetScrim { fade.value }.clickable(enabled = visible, onClick = {}) + .pointerInput(visible) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + if (!visible) event.changes.forEach { it.consume() } + } + } + }.testTag("discover-overlay")) { Column(Modifier.align(Alignment.CenterEnd).width(L.sheetWidth).fillMaxSize() + .sheetPanelFade { fade.value } .dropShadow(RectangleShape, Shadow(radius = WolfskinTokens.Lift.sheetBlur, color = Color.Black.copy(alpha = WolfskinTokens.Lift.sheetOpacity), offset = DpOffset(WolfskinTokens.Lift.sheetX, WolfskinTokens.Shell.zero))) .background(C.surface).padding(D.sheetPaddingX, D.sheetPaddingY), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) { - when (overlay) { - is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state) - is DiscoverOverlayState.Hidden -> { - ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.") - Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title, showToast = false) } - DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok) } + key(overlay) { + when (overlay) { + is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state, visible) + is DiscoverOverlayState.Hidden -> { + ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.") + Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { + DiscoverButton("Undo", overlay.focus == 0, enabled = visible) { state.restore(overlay.title, showToast = false) } + DiscoverButton("Done", overlay.focus == 1, primary = false, enabled = visible) { overlay.focus = 1; state.key(ShellKey.Ok) } + } } - } - is DiscoverOverlayState.Restore -> { - Text(overlay.title.title, Modifier.discoverLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle) - Text("Hidden from ${state.shell.person.name}’s picks.", - Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body), - color = C.muted, style = DiscoverType.body) - Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title, showToast = true) } - DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null } + is DiscoverOverlayState.Restore -> { + Text(overlay.title.title, Modifier.discoverLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle) + Text("Hidden from ${state.shell.person.name}’s picks.", + Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body), + color = C.muted, style = DiscoverType.body) + Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { + DiscoverButton("Show again", overlay.focus == 0, enabled = visible) { state.restore(overlay.title, showToast = true) } + DiscoverButton("Back", overlay.focus == 1, primary = false, enabled = visible) { state.overlay = null } + } } } } } } } - state.toast?.let { RecoveryToast(it) } + RecoveryToastHost(state.toast) } @Composable -private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) { +private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState, interactive: Boolean) { + var previousPhase by remember(sheet) { mutableStateOf(sheet.phase) } + val phase = sheet.phase + SideEffect { previousPhase = phase } Text(if (sheet.held) "Add for someone" else "Request", Modifier.exactLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle) Text("${sheet.title.title} · ${sheet.title.typeWord}${sheet.title.year?.let { " · $it" }.orEmpty()}", Modifier.exactLineBox(DiscoverType.body, top = D.sheetSubTop, bottom = D.sheetSubGap), color = C.muted, style = DiscoverType.body) if (state.isSignedIn) SheetRatings(sheet.title.ratings) - when (sheet.phase) { - RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet) + when (phase) { + RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet, interactive) RequestPhase.LoadingRecipients -> { Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label) repeat(3) { @@ -89,10 +121,21 @@ private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) { } } Box(Modifier.padding(top = D.sheetButtonTop)) { - DiscoverButton("Close", true, primary = false) { state.overlay = null } + DiscoverButton("Close", true, primary = false, enabled = interactive) { state.overlay = null } } } - else -> RequestOutcome(state, sheet) + else -> { + val opacity = remember(sheet, phase) { Animatable(if (previousPhase == RequestPhase.Sending) 0f else 1f) } + val reduced = reducedMotion() + LaunchedEffect(opacity, reduced) { + if (opacity.value < 1f) opacity.animateTo(1f, + tween(motionDuration(WolfskinTokens.Motion.lateLineFade, reduced), easing = WolfskinTokens.Motion.ease)) + } + Column(Modifier.fillMaxWidth().graphicsLayer { + alpha = opacity.value + compositingStrategy = CompositingStrategy.ModulateAlpha + }, verticalArrangement = Arrangement.spacedBy(D.sheetGap)) { RequestOutcome(state, sheet, interactive) } + } } } @@ -119,7 +162,7 @@ private fun SheetRatings(ratings: List) { } @Composable -private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) { +private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState, interactive: Boolean) { Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label) sheet.people.forEachIndexed { index, person -> val focused = sheet.focus == index @@ -127,7 +170,7 @@ private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) { Row(Modifier.fillMaxWidth().height(L.sheetRow).alpha(if (disabled) .5f else 1f) .focusScale(focused, WolfskinTokens.Focus.scaleRow) .background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)) - .clickable(enabled = !disabled && sheet.phase == RequestPhase.Choose) { sheet.selected = index; sheet.focus = sheet.buttonIndex } + .clickable(enabled = interactive && !disabled && sheet.phase == RequestPhase.Choose) { sheet.selected = index; sheet.focus = sheet.buttonIndex } .padding(horizontal = D.sheetRowPadding), horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) { Avatar(person, D.sheetAvatar, WolfskinTokens.Type.avatarLetterSheetRow) Text(person.name + if (person.key == sheet.viewer.key) " (you)" else "", Modifier.weight(1f), color = if (focused) C.onFocus else C.fg, style = DiscoverType.listTitle) @@ -141,12 +184,16 @@ private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) { if (sheet.blocked) Text("Season details are unknown right now, so this series can’t be requested yet. Nothing was sent.", color = C.warn, style = DiscoverType.caption) else if (sheet.title.type == "tv" && !sheet.held) Text("Whole series: every listed season. Other seasons may download too.", color = C.dim, style = DiscoverType.caption) Box(Modifier.padding(top = D.sheetButtonTop)) { - DiscoverButton(if (sheet.blocked) "Close" else sheet.buttonLabel, sheet.focus == sheet.buttonIndex, glyph = if (sheet.blocked) null else "plus", primary = !sheet.blocked) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) } + DiscoverButton(if (sheet.blocked) "Close" else sheet.buttonLabel, sheet.focus == sheet.buttonIndex, + glyph = if (sheet.blocked) null else "plus", primary = !sheet.blocked, enabled = interactive, + busy = sheet.phase == RequestPhase.Sending, busyLabel = if (sheet.blocked) null else "Sending…") { + sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) + } } } @Composable -private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) { +private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState, interactive: Boolean) { val phase = sheet.phase val name = sheet.selectedPerson.name val actor = sheet.result?.actor @@ -169,9 +216,9 @@ private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) { if (phase == RequestPhase.Receipt) Text("Follow it under Requests in Settings.", color = C.dim, style = DiscoverType.caption) Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { if (phase in setOf(RequestPhase.NotConfirmed, RequestPhase.Pending, RequestPhase.RecipientsUnavailable)) { - DiscoverButton(if (phase == RequestPhase.RecipientsUnavailable) "Try again" else "Check again", sheet.focus == sheet.buttonIndex) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) } - DiscoverButton("Close", sheet.focus != sheet.buttonIndex, primary = false) { state.overlay = null } - } else DiscoverButton(if (phase == RequestPhase.Receipt) "Done" else "Close", true) { state.overlay = null } + DiscoverButton(if (phase == RequestPhase.RecipientsUnavailable) "Try again" else "Check again", sheet.focus == sheet.buttonIndex, enabled = interactive) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) } + DiscoverButton("Close", sheet.focus != sheet.buttonIndex, primary = false, enabled = interactive) { state.overlay = null } + } else DiscoverButton(if (phase == RequestPhase.Receipt) "Done" else "Close", true, enabled = interactive) { state.overlay = null } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt index 48972d26..620d9f66 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt @@ -14,7 +14,9 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -193,11 +195,15 @@ private fun DiscoverChips(state: DiscoverState) { Row(Modifier.fillMaxWidth().height(L.row_chips).padding(start = L.safeSide, top = D.chipTop), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) { (if (state.service == null) listOf("all" to "All", "movie" to "Movies", "tv" to "Series") else listOf("movie" to "Movies", "tv" to "Series")).forEachIndexed { index, (value, label) -> val focused = state.contentFocused && state.focusedRow == 1 && state.column == index + val selected = state.filter == value + val fill = animatedFocusColor(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest) + val foreground = animatedFocusColor(if (focused) C.onFocus else if (selected) C.accentText else C.fg) + val border = animatedFocusColor(if (selected && !focused) C.accent else C.accent.copy(alpha = 0f)) Box(Modifier.height(D.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) - .background(if (focused) C.fg else if (state.filter == value) C.accentSoft else C.chipRest, RoundedCornerShape(WolfskinTokens.Radius.chip)) - .border(if (!focused && state.filter == value) WolfskinTokens.Focus.ringCardRing / 2 else WolfskinTokens.Shell.zero, if (!focused && state.filter == value) C.accent else C.chipRest.copy(alpha = 0f), RoundedCornerShape(WolfskinTokens.Radius.chip)) + .background(fill, RoundedCornerShape(WolfskinTokens.Radius.chip)) + .border(WolfskinTokens.Focus.ringCardRing / 2, border, RoundedCornerShape(WolfskinTokens.Radius.chip)) .clickable { state.focus(1, index); state.key(ShellKey.Ok) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) { - Text(label, color = if (focused) C.onFocus else if (state.filter == value) C.accentText else C.fg, style = DiscoverType.label) + Text(label, color = foreground, style = DiscoverType.label) } } } @@ -223,12 +229,21 @@ private fun DiscoverStatePanel(state: DiscoverState) { /** Hero and sheet buttons reuse the shell's scale and outside-ring primitives. */ @Composable -fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) { +fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, + busy: Boolean = false, busyLabel: String? = null, enabled: Boolean = true, onClick: () -> Unit) { + val foreground = if (primary) C.onFocus else C.fg + val content: @Composable RowScope.() -> Unit = { + if (glyph != null) Glyph(glyph, foreground, Modifier.size(L.buttonIcon)) + Text(label, color = foreground, style = DiscoverType.button, maxLines = 1) + } Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) .background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.button)) - .clickable(onClick = onClick).padding(horizontal = L.buttonPaddingX), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) { - if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon)) - Text(label, color = if (primary) C.onFocus else C.fg, style = DiscoverType.button, maxLines = 1) + .clickable(enabled = enabled, onClick = onClick).padding(horizontal = L.buttonPaddingX), + horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) { + if (busyLabel == null) content() + else BusyLabelSwap(busy, busyLabel, color = foreground, style = DiscoverType.button) { + Row(Modifier.fillMaxHeight(), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically, content = content) + } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt index d4f09754..35ed0809 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt @@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.design import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.derivedStateOf import java.util.Locale @@ -82,6 +83,7 @@ class LibraryGridState( var pagination: LibraryPagination? = null var pageRevision by mutableStateOf(0) var refreshRevision by mutableStateOf(0) + var resultRevision by mutableIntStateOf(0) var pointerReentry: (() -> Int?)? = null var index by mutableStateOf(0) var toolbar by mutableStateOf(false) @@ -147,8 +149,10 @@ class LibraryGridState( ShellKey.Left -> tool = (tool - 1).coerceAtLeast(0) ShellKey.Right -> tool = (tool + 1).coerceAtMost(count - 1) ShellKey.Ok -> { + val previous = items.map { if (catalogue) it.catalogueKey else it.id } if (catalogue) mediaType = listOf("all", "movie", "tv")[tool] else if (tool == 0) sort = (sort + 1) % 3 else unwatched = !unwatched + if (previous != items.map { if (catalogue) it.catalogueKey else it.id }) resultRevision++ index = 0 } else -> Unit diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt index bef9e51a..e6dd0982 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt @@ -137,7 +137,7 @@ fun LibraryScreen( }.uniqueKeys() items(rowCount, key = { rowKeys[it] }, contentType = { "grid" }) { row -> Box(Modifier.fillMaxWidth().height(rowHeight)) { - Row(Modifier.padding(start = L.safeSide).rowReveal("grid-$row"), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { + Row(Modifier.padding(start = L.safeSide).rowReveal("grid-${grid.resultRevision}-$row"), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { for (column in 0 until grid.columns) { val index = row * grid.columns + column val title = items.getOrNull(index) ?: break @@ -194,12 +194,14 @@ private fun LibraryToolbar(grid: LibraryGridState, active: Boolean) { labels.forEachIndexed { index, label -> val focused = active && grid.toolbar && grid.tool == index val selected = if (grid.catalogue) listOf("all", "movie", "tv")[index] == grid.mediaType else index == 1 && grid.unwatched + val fill = animatedFocusColor(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest) + val color = animatedFocusColor(if (focused) C.onFocus else if (selected) C.accentText else C.fg) + val border = animatedFocusColor(if (selected && !focused) C.accent else C.accent.copy(alpha = 0f)) Row(Modifier.height(G.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) - .background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, CircleShape) - .then(if (selected && !focused) Modifier.border(G.borderWidth, C.accent, CircleShape) else Modifier) + .background(fill, CircleShape) + .border(G.borderWidth, border, CircleShape) .clickable { grid.toolbar = true; grid.tool = index; grid.key(ShellKey.Ok) } .padding(horizontal = G.chipPadding), horizontalArrangement = Arrangement.spacedBy(G.chipContentGap), verticalAlignment = Alignment.CenterVertically) { - val color = if (focused) C.onFocus else if (selected) C.accentText else C.fg if (!grid.catalogue) Glyph(if (index == 0) "advanced" else "eye", color, Modifier.size(G.chipIcon)) if (!grid.catalogue && index == 0) Text(buildAnnotatedString { withStyle(SpanStyle(color = color.copy(alpha = G.chipLabelOpacity), fontWeight = FontWeight.Medium)) { append("Sort") } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Motion.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Motion.kt index 111966df..4ad6c0be 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Motion.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Motion.kt @@ -74,14 +74,25 @@ fun Modifier.skeletonPulse(loading: Boolean = true): Modifier { return graphicsLayer { alpha = clock.alpha.value } } -/** Content enters on its row layer, never one animation per card or a full-screen transition. */ +/** + * Content enters on its row layer, never per card or across the full screen. + * ScreenEntry remembers each enabled identity for that saved visit, so lazy disposal and Back + * do not replay its entrance. Outside an entry, each new composition retains the original reveal. + */ @Composable fun Modifier.rowReveal(identity: Any, enabled: Boolean = true): Modifier { + val memory = LocalRevealMemory.current + val key = identity.toString() + val shouldReveal = remember(memory, identity, enabled) { enabled && memory?.identities?.contains(key) != true } + if (!shouldReveal) return this val reduced = reducedMotion() - val progress = remember(identity, enabled) { Animatable(if (enabled) 0f else 1f) } - LaunchedEffect(progress, reduced) { if (enabled) progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) } - val rise = with(LocalDensity.current) { M.rowRise.toPx() } - return if (!enabled) this else graphicsLayer { + val progress = remember(memory, identity, enabled) { Animatable(0f) } + LaunchedEffect(progress, reduced) { + memory?.identities?.add(key) + progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) + } + val rise = if (reduced) 0f else with(LocalDensity.current) { M.rowRise.toPx() } + return graphicsLayer { alpha = progress.value translationY = if (reduced) 0f else rise * (1f - progress.value) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt index 503f5338..b3710f1f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt @@ -4,6 +4,20 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable + +/** Entrance identities belong to one navigation entry, not the lifetime of a recycled lazy item. */ +internal class RevealMemory(val identities: MutableSet = mutableSetOf()) { + companion object { + val saver = Saver>( + save = { ArrayList(it.identities) }, + restore = { RevealMemory(it.toMutableSet()) }, + ) + } +} + +internal val LocalRevealMemory = compositionLocalOf { null } internal val LocalScreenView = compositionLocalOf { null } internal val LocalScreenActive = compositionLocalOf { true } @@ -16,7 +30,9 @@ fun isScreenEntryActive(): Boolean = LocalScreenActive.current @Composable fun ScreenEntry(shell: ShellState, route: String, active: Boolean, content: @Composable () -> Unit) { remember(shell, shell.person.key, route) { shell.prepareDestinationView(route) } - CompositionLocalProvider(LocalScreenView provides shell.rememberedView(route), LocalScreenActive provides active, content = content) + val revealMemory = rememberSaveable(shell.person.key, route, saver = RevealMemory.saver) { RevealMemory() } + CompositionLocalProvider(LocalScreenView provides shell.rememberedView(route), LocalScreenActive provides active, + LocalRevealMemory provides revealMemory, content = content) } /** Direct surface previews use the current view when no navigation entry is installed. */ diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverPolishTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverPolishTest.kt new file mode 100644 index 00000000..f56f928d --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverPolishTest.kt @@ -0,0 +1,242 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C + +/** The shipped Discover surface preserves geometry, input and settled pixels through polish motion. */ +@RunWith(ParameterizedRobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class DiscoverPolishTest(private val reduced: Boolean) { + companion object { + @JvmStatic @ParameterizedRobolectricTestRunner.Parameters(name = "reduced={0}") + fun motionModes() = listOf(arrayOf(false), arrayOf(true)) + } + @get:Rule val rule = createAndroidComposeRule() + private val fixture = discoverTestFixture() + private val snapshot = fixture.snapshot("justin") + private val scope = CoroutineScope(Dispatchers.Main.immediate) + private val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") } + private val answer = CompletableDeferred() + private var requestCount = 0 + private val operations = object : DiscoverOperations by RecordingDiscoverOperations(snapshot, "justin") { + override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult { requestCount++; return answer.await() } + } + private val state = DiscoverState(shell, snapshot, operations, scope, {}, {}, fixture::service) + + @Before fun fullscreen() = polishFullscreen(rule.activity) + @After fun cancelRequests() { scope.cancel() } + private fun mount() { + val painters = mutableMapOf() + rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalReducedMotion provides reduced, + LocalDiscoverArt provides { path -> painters.getOrPut(path) { BitmapFactory.decodeFile(path).asImageBitmap() } }) { + ScreenEntry(shell, "discover", true) { + ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), overlay = { DiscoverOverlay(state) }) { DiscoverScreen(state, it) } + } + } } } + rule.mainClock.advanceTimeBy(1000) + rule.mainClock.autoAdvance = false + } + private fun request(phase: RequestPhase = RequestPhase.Choose): RequestSheetState { + state.openRequest(true, snapshot.picks.first { it.type == "movie" }, phase) + return (state.overlay as DiscoverOverlayState.Request).state.apply { + selected = people.indexOfFirst { it.key == "mindel" }; focus = buttonIndex + } + } + + @Test fun chipColoursCrossfadeOverFocusMove() { + state.focus(1, 0) + mount() + val all = rule.onNodeWithText("All", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val movies = rule.onNode(hasText("Movies") and hasAnyAncestor(hasTestTag("discover-screen")), useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val x = movies.center.x.toInt() + val y = (movies.top - 5).toInt() + val frames = PolishFrames(rule, "P01", "discover-chips", reduced) + val resting = frames.capture("settled-before", 0, x, y) + updatePolishState(rule) { state.key(ShellKey.Right) } + rule.mainClock.advanceTimeBy(32) + val images = frames.strip(16, x = x, y = y) + frames.finish() + assertEquals("First focus frame keeps old fill", resting.getPixel(x, y), images.first().getPixel(x, y)) + assertPolishMonotonic(images.map { polishBrightness(it, x, y) }) + val midway = images[if (reduced) 2 else 5].getPixel(x, y) + assertNotEquals("Focus colour must interpolate rather than pop", images.first().getPixel(x, y), midway) + assertNotEquals("Focus colour must not finish early", images.last().getPixel(x, y), midway) + assertPolishMonotonic(images.map { polishBrightness(it, all.center.x.toInt(), y) }) + val settledIndex = (if (reduced) 80 else 180) / 16 + 1 + assertEquals(C.fg.toArgb(), images[settledIndex].getPixel(x, y)) + assertEquals("The departed selected chip keeps its selected fill", C.accentSoft.toArgb(), images.last().getPixel(all.center.x.toInt(), y)) + resting.recycle(); images.forEach(Bitmap::recycle) + } + + @Test fun requestButtonSaysSendingUntilTheServerAnswers() { + val sheet = request() + mount() + val label = sheet.buttonLabel + val restingBounds = rule.onNode(hasClickAction() and hasText(label)).fetchSemanticsNode().boundsInRoot + val frames = PolishFrames(rule, "P03", "sending", reduced) + frames.capture("settled-before", 0).recycle() + updatePolishState(rule) { state.key(ShellKey.Ok) } + rule.mainClock.advanceTimeByFrame() + val strip = frames.strip(12) + rule.onNodeWithText("Sending…").assertIsDisplayed() + val busyBounds = rule.onNode(hasClickAction() and hasText("Sending…")).fetchSemanticsNode().boundsInRoot + assertEquals(restingBounds.width, busyBounds.width, 0f) + rule.onNodeWithText(label).assertDoesNotExist() + rule.onNodeWithText("Requested").assertDoesNotExist() + updatePolishState(rule) { state.key(ShellKey.Ok) } + assertEquals(RequestPhase.Sending, sheet.phase) + assertEquals("Repeated OK cannot send twice", 1, requestCount) + updatePolishState(rule) { state.key(ShellKey.Back) } + rule.mainClock.advanceTimeByFrame() + updatePolishState(rule) { request() } + rule.mainClock.advanceTimeBy(32) + val reopened = frames.capture("reopened-sending", 0) + assertPolishRegionEquals(strip.last(), reopened, busyBounds, "Reopening a sending sheet shows the busy label at rest") + assertEquals(1, requestCount) + reopened.recycle(); strip.forEach(Bitmap::recycle) + frames.finish() + updatePolishState(rule) { answer.complete(DiscoverRequestResult(RequestPhase.Receipt, "Justin")) } + rule.mainClock.advanceTimeBy(240) + rule.onNodeWithText("Requested").assertIsDisplayed() + rule.onNodeWithText("Sending…").assertDoesNotExist() + } + + @Test fun receiptFadesInOnceAfterSending() { + val sheet = request() + mount() + updatePolishState(rule) { state.key(ShellKey.Ok) } + rule.mainClock.advanceTimeBy(240) + updatePolishState(rule) { answer.complete(DiscoverRequestResult(RequestPhase.Receipt, "Justin")) } + rule.mainClock.advanceTimeBy(32) + val heading = rule.onNodeWithText("Requested", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val x = (heading.left + 5).toInt() + val y = (heading.bottom + 5).toInt() + val frames = PolishFrames(rule, "P04", "receipt", reduced) + assertEquals("Done owns logical focus from frame zero", sheet.buttonIndex, sheet.focus) + rule.onNodeWithText("Done").assertExists() + val images = frames.strip(15, x = x, y = y) + frames.finish() + assertPolishMonotonic(images.map { polishBrightness(it, x, y) }) + val settledIndex = (if (reduced) 80 else 200) / 16 + 1 + assertEquals(images.last().getPixel(x, y), images[settledIndex].getPixel(x, y)) + images.forEach(Bitmap::recycle) + } + + @Test fun reopenedUnconfirmedOutcomeRestsImmediately() { + request() + mount() + updatePolishState(rule) { state.key(ShellKey.Ok) } + rule.mainClock.advanceTimeBy(240) + updatePolishState(rule) { answer.complete(DiscoverRequestResult(RequestPhase.NotConfirmed, "Justin")) } + rule.mainClock.advanceTimeBy(240) + val heading = rule.onNodeWithText("Not confirmed", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val frames = PolishFrames(rule, "P04", "unconfirmed-reopen", reduced) + val settled = frames.capture("settled", 0) + updatePolishState(rule) { state.key(ShellKey.Back) } + rule.mainClock.advanceTimeByFrame() + updatePolishState(rule) { request() } + rule.mainClock.advanceTimeBy(32) + val reopened = frames.capture("reopened", 0) + frames.finish() + assertPolishRegionEquals(settled, reopened, heading, "An outcome that reopens must be fully opaque immediately") + settled.recycle(); reopened.recycle() + } + + @Test fun requestSheetFadesInAndOutWithoutClickThrough() { + mount() + val frames = PolishFrames(rule, "P06", "request-sheet", reduced) + frames.capture("settled-before", 0).recycle() + val details = rule.onNode(hasClickAction() and hasText("Details")).fetchSemanticsNode().boundsInRoot.center + updatePolishState(rule) { request() } + rule.mainClock.advanceTimeBy(32) + val entering = frames.strip(12, "open", 1900, 900) + val heading = rule.onNodeWithText("Request", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + var panelX = heading.left.toInt() + var panelY = heading.top.toInt() + var brightest = -1 + // Interior text avoids the one-level RGB rounding of a nearly opaque dark panel. + for (y in heading.top.toInt() until heading.bottom.toInt()) { + for (x in heading.left.toInt() until heading.right.toInt()) { + val brightness = polishBrightness(entering.last(), x, y) + if (brightness > brightest) { brightest = brightness; panelX = x; panelY = y } + } + } + val button = rule.onNode(hasClickAction() and hasText("Request for Mindel")).fetchSemanticsNode().boundsInRoot.center + val navFocused = shell.navFocused + val row = state.focusedRow + updatePolishState(rule) { state.key(ShellKey.Back) } + rule.mainClock.advanceTimeBy(32) + rule.onNodeWithTag("discover-overlay").assertExists() + val focusTargets = rule.onAllNodes(hasAnyAncestor(hasTestTag("discover-overlay")) and + SemanticsMatcher.keyIsDefined(SemanticsActions.RequestFocus), useUnmergedTree = true).fetchSemanticsNodes() + updatePolishState(rule) { + focusTargets.forEach { node -> + assertFalse("Exit copies must refuse focus", node.config[SemanticsActions.RequestFocus].action?.invoke() == true) + } + } + rule.onAllNodes(hasAnyAncestor(hasTestTag("discover-overlay")) and isFocused()).assertCountEquals(0) + rule.onRoot().performTouchInput { click(button); click(details) } + assertNull("Retained controls cannot reopen or act on the sheet", state.overlay) + assertNull("Pointers cannot fall through to Details", state.title) + val leaving = frames.strip(8, "close", 1900, 900) + entering.forEachIndexed { index, image -> frames.sample("open-panel", index, image, panelX, panelY) } + leaving.forEachIndexed { index, image -> frames.sample("close-panel", index, image, panelX, panelY) } + frames.finish() + rule.onNodeWithTag("discover-overlay").assertDoesNotExist() + assertEquals(navFocused, shell.navFocused) + assertEquals(row, state.focusedRow) + assertPolishMonotonic(entering.map { polishBrightness(it, panelX, panelY) }) + assertPolishMonotonic(entering.map { polishBrightness(it, 400, 900) }) + assertPolishMonotonic(leaving.map { polishBrightness(it, panelX, panelY) }) + assertPolishMonotonic(leaving.map { polishBrightness(it, 400, 900) }) + val middle = entering[if (reduced) 2 else 5].getPixel(panelX, panelY) + assertNotEquals(entering.first().getPixel(panelX, panelY), middle) + assertNotEquals(entering.last().getPixel(panelX, panelY), middle) + entering.forEach(Bitmap::recycle); leaving.forEach(Bitmap::recycle) + } + + @Test fun scrollBackLaneDoesNotReplay() { + state.focus(state.firstShelf, 0) + mount() + val first = state.lanes.first() + val frames = PolishFrames(rule, "E03", "discover-scroll-back", reduced) + val settled = frames.capture("settled", 0) + updatePolishState(rule) { shell.view.listState.requestScrollToItem(state.lanes.lastIndex + state.firstShelf) } + rule.mainClock.advanceTimeBy(240) + rule.onNodeWithTag("discover-lane-${first.id}").assertDoesNotExist() + updatePolishState(rule) { shell.view.listState.requestScrollToItem(state.firstShelf) } + rule.mainClock.advanceTimeBy(32) + val images = frames.strip(15) + frames.finish() + val restoredBounds = rule.onNodeWithTag("poster-${first.titles[1].key}").fetchSemanticsNode().boundsInRoot + assertPolishRegionEquals(images.last(), images.first(), restoredBounds, "Seen Discover lane must not re-enter") + settled.recycle(); images.forEach(Bitmap::recycle) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomePolishRowsTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomePolishRowsTest.kt new file mode 100644 index 00000000..92053eeb --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomePolishRowsTest.kt @@ -0,0 +1,143 @@ +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.snapshots.Snapshot +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.test.ext.junit.rules.ActivityScenarioRule +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Native frames prove that recycled shelves keep their settled pixels, including reduced motion. */ +@RunWith(ParameterizedRobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class HomePolishRowsTest(private val reduced: Boolean) { + companion object { + @JvmStatic @ParameterizedRobolectricTestRunner.Parameters(name = "reduced={0}") + fun motionModes() = listOf(arrayOf(false), arrayOf(true)) + } + @get:Rule val rule = createAndroidComposeRule() + @Before fun fullscreen() = polishFullscreen(rule.activity) + + @Test fun scrollBackShelfDoesNotReplay() { + val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("home"); navFocused = true } + val rows = List(8) { row -> HomeRow("shelf-$row", HomeRowKind.Shelf, title = "Shelf $row", cards = List(8) { HomeTitle("$row-$it", "Film $it", "movie") }) } + rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalReducedMotion provides reduced) { + ScreenEntry(shell, "home", true) { + ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { HomeScreen(shell, HomeSnapshot(rows), it) {} } + } + } } } + rule.mainClock.advanceTimeBy(1000) + rule.mainClock.autoAdvance = false + val bounds = rule.onNodeWithTag("home-card:shelf-0:0").fetchSemanticsNode().boundsInRoot + val frames = PolishFrames(rule, "E03", "home-scroll-back", reduced) + val settled = frames.capture("settled", 0) + updatePolishState(rule) { shell.view.listState.requestScrollToItem(6) } + rule.mainClock.advanceTimeBy(32) + rule.onNodeWithTag("home-card:shelf-0:0").assertDoesNotExist() + val freshBounds = rule.onNodeWithTag("home-card:shelf-6:0").fetchSemanticsNode().boundsInRoot + val fresh = frames.capture("first-appearance", 0) + rule.mainClock.advanceTimeBy(240) + val freshSettled = frames.capture("first-appearance-settled", 0) + updatePolishState(rule) { shell.view.listState.requestScrollToItem(0) } + rule.mainClock.advanceTimeBy(32) + val returning = frames.strip(15) + frames.finish() + assertPolishRegionEquals(settled, returning.first(), bounds, "Seen Home shelf must be at rest on its first frame back") + val x = (freshBounds.left + 30).toInt() + val y = (freshBounds.top + 80).toInt() + assertNotEquals("A genuinely new shelf still enters", fresh.getPixel(x, y), freshSettled.getPixel(x, y)) + settled.recycle(); fresh.recycle(); freshSettled.recycle(); returning.forEach(Bitmap::recycle) + } +} + +/** Flush state writes before advancing the manual animation clock. */ +internal fun updatePolishState( + rule: AndroidComposeTestRule, ComponentActivity>, + update: () -> Unit, +) { + rule.runOnIdle { update(); Snapshot.sendApplyNotifications() } +} + +/** Keep captures on the actual full-screen native surface used by the TV. */ +internal fun polishFullscreen(activity: ComponentActivity) { + activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY +} + +/** A manual-clock strip records native pixels and sample coordinates for independent review. */ +internal class PolishFrames( + private val rule: AndroidComposeTestRule, ComponentActivity>, + id: String, + private val name: String, + private val reduced: Boolean, +) { + private val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/$id").apply { mkdirs() } + private val samples = mutableListOf("phase,timeMs,x,y,red,green,blue") + fun capture(phase: String, index: Int, x: Int = 400, y: Int = 900): Bitmap { + rule.waitForIdle() + val file = File(directory, "$name${if (reduced) "-reduced" else ""}-$phase-${index.toString().padStart(2, '0')}.png") + rule.onRoot().captureRoboImage(file.absolutePath) + return BitmapFactory.decodeFile(file.absolutePath).also { image -> + for ((sampleX, sampleY) in listOf(x to y, 400 to 900).distinct()) { + sample(phase, index, image, sampleX, sampleY) + } + } + } + fun strip(count: Int, phase: String = "motion", x: Int = 400, y: Int = 900): List = List(count) { index -> + capture(phase, index, x, y).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() } + } + fun sample(phase: String, index: Int, image: Bitmap, x: Int, y: Int) { + val pixel = image.getPixel(x, y) + samples += "$phase,${index * 16},$x,$y,${android.graphics.Color.red(pixel)},${android.graphics.Color.green(pixel)},${android.graphics.Color.blue(pixel)}" + } + fun finish() { + File(directory, "$name${if (reduced) "-reduced" else ""}-samples.csv").writeText(samples.joinToString("\n") + "\n") + val combined = buildString { + appendLine("strip,phase,timeMs,x,y,red,green,blue") + directory.listFiles { file -> file.name.endsWith("-samples.csv") }.orEmpty().sortedBy { it.name }.forEach { file -> + file.readLines().drop(1).forEach { appendLine("${file.nameWithoutExtension},$it") } + } + } + File(directory, "samples.csv").writeText(combined) + } +} + +internal fun polishBrightness(image: Bitmap, x: Int, y: Int): Int = image.getPixel(x, y).let { + android.graphics.Color.red(it) + android.graphics.Color.green(it) + android.graphics.Color.blue(it) +} + +internal fun assertPolishMonotonic(values: List, changing: Boolean = true) { + if (changing) assertNotEquals("Motion must change the sampled pixel: $values", values.first(), values.last()) + val direction = if (values.last() >= values.first()) 1 else -1 + values.zipWithNext().forEach { (before, after) -> assertTrue("Pixel must move monotonically: $values", (after - before) * direction >= -2) } +} + +internal fun assertPolishRegionEquals(expected: Bitmap, actual: Bitmap, bounds: Rect, message: String) { + val inset = 12 + for (y in bounds.top.toInt() + inset until bounds.bottom.toInt() - inset step 4) { + for (x in bounds.left.toInt() + inset until bounds.right.toInt() - inset step 4) { + assertEquals("$message at ($x,$y)", expected.getPixel(x, y), actual.getPixel(x, y)) + } + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryPolishTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryPolishTest.kt new file mode 100644 index 00000000..f361f7ba --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryPolishTest.kt @@ -0,0 +1,129 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.graphics.Bitmap +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C + +/** Library transitions reveal real changed results without replaying a retained navigation entry. */ +@RunWith(ParameterizedRobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class LibraryPolishTest(private val reduced: Boolean) { + companion object { + @JvmStatic @ParameterizedRobolectricTestRunner.Parameters(name = "reduced={0}") + fun motionModes() = listOf(arrayOf(false), arrayOf(true)) + } + @get:Rule val rule = createAndroidComposeRule() + private val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("movies"); navFocused = false } + private val titles = List(18) { index -> + val score = when (index % 6) { 0 -> index + 4; 4 -> index - 4; else -> index } / 2.0 + LibraryTitle("film-$index", "Film ${index.toString().padStart(2, '0')}", "movie", addedAt = "${100 + index}", ratings = listOf(LibraryRating("tmdb", score))) + } + private val grid = LibraryGridState("Movies", titles).apply { toolbar = true } + private var entry by mutableStateOf("movies-entry") + @Before fun fullscreen() = polishFullscreen(rule.activity) + private fun mount() { + rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalReducedMotion provides reduced) { + val holder = rememberSaveableStateHolder() + entry?.let { key -> holder.SaveableStateProvider(key) { + ScreenEntry(shell, "movies", true) { + ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { LibraryScreen(shell, grid, it) } + } + } } + } } } + rule.mainClock.advanceTimeBy(1000) + rule.mainClock.autoAdvance = false + } + + @Test fun toolbarChipColoursCrossfadeOverFocusMove() { + mount() + val chip = rule.onNodeWithText("All", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val x = chip.center.x.toInt() + val y = (chip.top - 5).toInt() + val frames = PolishFrames(rule, "P01", "library-toolbar", reduced) + val resting = frames.capture("settled-before", 0, x, y) + updatePolishState(rule) { grid.key(ShellKey.Right) } + rule.mainClock.advanceTimeBy(32) + val images = frames.strip(16, x = x, y = y) + frames.finish() + assertEquals(resting.getPixel(x, y), images.first().getPixel(x, y)) + assertPolishMonotonic(images.map { polishBrightness(it, x, y) }) + val midway = images[if (reduced) 2 else 5].getPixel(x, y) + assertNotEquals("Focus colour must interpolate rather than pop", images.first().getPixel(x, y), midway) + assertNotEquals("Focus colour must not finish early", images.last().getPixel(x, y), midway) + assertEquals(C.fg.toArgb(), images[(if (reduced) 80 else 180) / 16 + 1].getPixel(x, y)) + resting.recycle(); images.forEach(Bitmap::recycle) + } + + @Test fun backFromTitleKeepsRowsAtRest() { + mount() + val bounds = rule.onNodeWithTag("poster-film-0").fetchSemanticsNode().boundsInRoot + val frames = PolishFrames(rule, "P05", "library-back", reduced) + val settled = frames.capture("settled", 0) + updatePolishState(rule) { entry = null } + rule.mainClock.advanceTimeBy(32) + rule.onNodeWithTag("library-grid").assertDoesNotExist() + updatePolishState(rule) { entry = "movies-entry" } + rule.mainClock.advanceTimeBy(32) + val restored = frames.strip(15) + updatePolishState(rule) { entry = null } + rule.mainClock.advanceTimeBy(32) + rule.onNodeWithTag("library-grid").assertDoesNotExist() + updatePolishState(rule) { entry = "fresh-movies-entry" } + rule.mainClock.advanceTimeBy(32) + val fresh = frames.strip(15, "fresh-entry") + frames.finish() + assertPolishRegionEquals(settled, restored.first(), bounds, "Back must restore the settled row at frame zero") + val x = (bounds.left + 25).toInt(); val y = (bounds.top + 25).toInt() + assertNotEquals("A fresh tab entry still reveals", fresh.last().getPixel(x, y), fresh.first().getPixel(x, y)) + settled.recycle(); restored.forEach(Bitmap::recycle); fresh.forEach(Bitmap::recycle) + } + + @Test fun sortChangeRevealsVisibleRowsTogether() { + grid.sort = 1 + mount() + val first = rule.onNodeWithTag("poster-film-17").fetchSemanticsNode().boundsInRoot + val second = rule.onNodeWithTag("poster-film-11").fetchSemanticsNode().boundsInRoot + val x = (first.left + 100).toInt() + val y0 = (first.top + 65).toInt(); val y1 = (second.top + 65).toInt() + val frames = PolishFrames(rule, "P11", "sort-rating", reduced) + frames.capture("settled-before", 0, x, y0).recycle() + updatePolishState(rule) { grid.key(ShellKey.Ok) } + rule.mainClock.advanceTimeBy(32) + val images = frames.strip(15, x = x, y = y0) + updatePolishState(rule) { grid.tool = 1 } + rule.mainClock.advanceTimeBy(240) + val beforeUnchanged = frames.capture("unchanged-before", 0, x, y0) + updatePolishState(rule) { grid.key(ShellKey.Ok) } + rule.mainClock.advanceTimeBy(32) + val afterUnchanged = frames.capture("unchanged-after", 0, x, y0) + frames.finish() + assertPolishMonotonic(images.map { polishBrightness(it, x, y0) }) + val background = C.bg.toArgb().let { android.graphics.Color.red(it) + android.graphics.Color.green(it) + android.graphics.Color.blue(it) } + fun ratio(y: Int): Double = (polishBrightness(images[2], x, y) - background).toDouble() / (polishBrightness(images.last(), x, y) - background) + assertEquals("Rows reveal on the same clock", ratio(y0), ratio(y1), .03) + assertTrue("Frame two is still entering", ratio(y0) in 0.01..0.99) + assertPolishRegionEquals(beforeUnchanged, afterUnchanged, first, "A filter with identical membership must not replay") + assertEquals(images.last().getPixel(x, y0), images[(if (reduced) 80 else 180) / 16 + 1].getPixel(x, y0)) + images.forEach(Bitmap::recycle); beforeUnchanged.recycle(); afterUnchanged.recycle() + } +}