Candidate 3: merge polish-lane-b

This commit is contained in:
root 2026-11-08 06:50:17 +01:00
commit e378f34f49
9 changed files with 658 additions and 49 deletions

View file

@ -1,6 +1,8 @@
// SPDX-License-Identifier: GPL-2.0-only // SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design 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.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable 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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape 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.graphics.shadow.Shadow
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.platform.testTag 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. */ /** Sheets draw after the shell nav: their scrim consumes pointers and focus stays inside. */
@Composable @Composable
fun DiscoverOverlay(state: DiscoverState) { fun DiscoverOverlay(state: DiscoverState) {
val overlay = state.overlay val current = state.overlay
if (overlay != null && !state.shell.pickerVisible) { var retained by remember { mutableStateOf(current) }
Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable(onClick = {}).testTag("discover-overlay")) { 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() Column(Modifier.align(Alignment.CenterEnd).width(L.sheetWidth).fillMaxSize()
.sheetPanelFade { fade.value }
.dropShadow(RectangleShape, Shadow(radius = WolfskinTokens.Lift.sheetBlur, .dropShadow(RectangleShape, Shadow(radius = WolfskinTokens.Lift.sheetBlur,
color = Color.Black.copy(alpha = WolfskinTokens.Lift.sheetOpacity), color = Color.Black.copy(alpha = WolfskinTokens.Lift.sheetOpacity),
offset = DpOffset(WolfskinTokens.Lift.sheetX, WolfskinTokens.Shell.zero))) offset = DpOffset(WolfskinTokens.Lift.sheetX, WolfskinTokens.Shell.zero)))
.background(C.surface).padding(D.sheetPaddingX, D.sheetPaddingY), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) { .background(C.surface).padding(D.sheetPaddingX, D.sheetPaddingY), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) {
when (overlay) { key(overlay) {
is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state) when (overlay) {
is DiscoverOverlayState.Hidden -> { is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state, visible)
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.") is DiscoverOverlayState.Hidden -> {
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { 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.")
DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title, showToast = false) } Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok) } 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 -> {
is DiscoverOverlayState.Restore -> { Text(overlay.title.title, Modifier.discoverLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle)
Text(overlay.title.title, Modifier.discoverLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle) Text("Hidden from ${state.shell.person.name}’s picks.",
Text("Hidden from ${state.shell.person.name}’s picks.", Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body),
Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body), color = C.muted, style = DiscoverType.body)
color = C.muted, style = DiscoverType.body) Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
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("Show again", overlay.focus == 0) { state.restore(overlay.title, showToast = true) } DiscoverButton("Back", overlay.focus == 1, primary = false, enabled = visible) { state.overlay = null }
DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null } }
} }
} }
} }
} }
} }
} }
state.toast?.let { RecoveryToast(it) } RecoveryToastHost(state.toast)
} }
@Composable @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(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()}", 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) Modifier.exactLineBox(DiscoverType.body, top = D.sheetSubTop, bottom = D.sheetSubGap), color = C.muted, style = DiscoverType.body)
if (state.isSignedIn) SheetRatings(sheet.title.ratings) if (state.isSignedIn) SheetRatings(sheet.title.ratings)
when (sheet.phase) { when (phase) {
RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet) RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet, interactive)
RequestPhase.LoadingRecipients -> { RequestPhase.LoadingRecipients -> {
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label) Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label)
repeat(3) { repeat(3) {
@ -89,10 +121,21 @@ private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
} }
} }
Box(Modifier.padding(top = D.sheetButtonTop)) { 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<DiscoverRating>) {
} }
@Composable @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) Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label)
sheet.people.forEachIndexed { index, person -> sheet.people.forEachIndexed { index, person ->
val focused = sheet.focus == index 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) Row(Modifier.fillMaxWidth().height(L.sheetRow).alpha(if (disabled) .5f else 1f)
.focusScale(focused, WolfskinTokens.Focus.scaleRow) .focusScale(focused, WolfskinTokens.Focus.scaleRow)
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)) .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) { .padding(horizontal = D.sheetRowPadding), horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) {
Avatar(person, D.sheetAvatar, WolfskinTokens.Type.avatarLetterSheetRow) 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) 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) 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) 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)) { 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 @Composable
private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) { private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState, interactive: Boolean) {
val phase = sheet.phase val phase = sheet.phase
val name = sheet.selectedPerson.name val name = sheet.selectedPerson.name
val actor = sheet.result?.actor 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) 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)) { Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
if (phase in setOf(RequestPhase.NotConfirmed, RequestPhase.Pending, RequestPhase.RecipientsUnavailable)) { 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(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) { state.overlay = null } DiscoverButton("Close", sheet.focus != sheet.buttonIndex, primary = false, enabled = interactive) { state.overlay = null }
} else DiscoverButton(if (phase == RequestPhase.Receipt) "Done" else "Close", true) { state.overlay = null } } else DiscoverButton(if (phase == RequestPhase.Receipt) "Done" else "Close", true, enabled = interactive) { state.overlay = null }
} }
} }

View file

@ -14,7 +14,9 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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)) { 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) -> (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 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) 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)) .background(fill, 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)) .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) { .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. */ /** Hero and sheet buttons reuse the shell's scale and outside-ring primitives. */
@Composable @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) Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) .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)) .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) { .clickable(enabled = enabled, onClick = onClick).padding(horizontal = L.buttonPaddingX),
if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon)) horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
Text(label, color = if (primary) C.onFocus else C.fg, style = DiscoverType.button, maxLines = 1) 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)
}
} }
} }

View file

@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.design
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import java.util.Locale import java.util.Locale
@ -82,6 +83,7 @@ class LibraryGridState(
var pagination: LibraryPagination? = null var pagination: LibraryPagination? = null
var pageRevision by mutableStateOf(0) var pageRevision by mutableStateOf(0)
var refreshRevision by mutableStateOf(0) var refreshRevision by mutableStateOf(0)
var resultRevision by mutableIntStateOf(0)
var pointerReentry: (() -> Int?)? = null var pointerReentry: (() -> Int?)? = null
var index by mutableStateOf(0) var index by mutableStateOf(0)
var toolbar by mutableStateOf(false) var toolbar by mutableStateOf(false)
@ -147,8 +149,10 @@ class LibraryGridState(
ShellKey.Left -> tool = (tool - 1).coerceAtLeast(0) ShellKey.Left -> tool = (tool - 1).coerceAtLeast(0)
ShellKey.Right -> tool = (tool + 1).coerceAtMost(count - 1) ShellKey.Right -> tool = (tool + 1).coerceAtMost(count - 1)
ShellKey.Ok -> { ShellKey.Ok -> {
val previous = items.map { if (catalogue) it.catalogueKey else it.id }
if (catalogue) mediaType = listOf("all", "movie", "tv")[tool] if (catalogue) mediaType = listOf("all", "movie", "tv")[tool]
else if (tool == 0) sort = (sort + 1) % 3 else unwatched = !unwatched 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 index = 0
} }
else -> Unit else -> Unit

View file

@ -137,7 +137,7 @@ fun LibraryScreen(
}.uniqueKeys() }.uniqueKeys()
items(rowCount, key = { rowKeys[it] }, contentType = { "grid" }) { row -> items(rowCount, key = { rowKeys[it] }, contentType = { "grid" }) { row ->
Box(Modifier.fillMaxWidth().height(rowHeight)) { 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) { for (column in 0 until grid.columns) {
val index = row * grid.columns + column val index = row * grid.columns + column
val title = items.getOrNull(index) ?: break val title = items.getOrNull(index) ?: break
@ -194,12 +194,14 @@ private fun LibraryToolbar(grid: LibraryGridState, active: Boolean) {
labels.forEachIndexed { index, label -> labels.forEachIndexed { index, label ->
val focused = active && grid.toolbar && grid.tool == index 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 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) Row(Modifier.height(G.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, CircleShape) .background(fill, CircleShape)
.then(if (selected && !focused) Modifier.border(G.borderWidth, C.accent, CircleShape) else Modifier) .border(G.borderWidth, border, CircleShape)
.clickable { grid.toolbar = true; grid.tool = index; grid.key(ShellKey.Ok) } .clickable { grid.toolbar = true; grid.tool = index; grid.key(ShellKey.Ok) }
.padding(horizontal = G.chipPadding), horizontalArrangement = Arrangement.spacedBy(G.chipContentGap), verticalAlignment = Alignment.CenterVertically) { .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) Glyph(if (index == 0) "advanced" else "eye", color, Modifier.size(G.chipIcon))
if (!grid.catalogue && index == 0) Text(buildAnnotatedString { if (!grid.catalogue && index == 0) Text(buildAnnotatedString {
withStyle(SpanStyle(color = color.copy(alpha = G.chipLabelOpacity), fontWeight = FontWeight.Medium)) { append("Sort") } withStyle(SpanStyle(color = color.copy(alpha = G.chipLabelOpacity), fontWeight = FontWeight.Medium)) { append("Sort") }

View file

@ -74,14 +74,25 @@ fun Modifier.skeletonPulse(loading: Boolean = true): Modifier {
return graphicsLayer { alpha = clock.alpha.value } 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 @Composable
fun Modifier.rowReveal(identity: Any, enabled: Boolean = true): Modifier { 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 reduced = reducedMotion()
val progress = remember(identity, enabled) { Animatable(if (enabled) 0f else 1f) } val progress = remember(memory, identity, enabled) { Animatable(0f) }
LaunchedEffect(progress, reduced) { if (enabled) progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) } LaunchedEffect(progress, reduced) {
val rise = with(LocalDensity.current) { M.rowRise.toPx() } memory?.identities?.add(key)
return if (!enabled) this else graphicsLayer { 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 alpha = progress.value
translationY = if (reduced) 0f else rise * (1f - progress.value) translationY = if (reduced) 0f else rise * (1f - progress.value)
} }

View file

@ -4,6 +4,20 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember 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<String> = mutableSetOf()) {
companion object {
val saver = Saver<RevealMemory, ArrayList<String>>(
save = { ArrayList(it.identities) },
restore = { RevealMemory(it.toMutableSet()) },
)
}
}
internal val LocalRevealMemory = compositionLocalOf<RevealMemory?> { null }
internal val LocalScreenView = compositionLocalOf<RememberedView?> { null } internal val LocalScreenView = compositionLocalOf<RememberedView?> { null }
internal val LocalScreenActive = compositionLocalOf { true } internal val LocalScreenActive = compositionLocalOf { true }
@ -16,7 +30,9 @@ fun isScreenEntryActive(): Boolean = LocalScreenActive.current
@Composable @Composable
fun ScreenEntry(shell: ShellState, route: String, active: Boolean, content: @Composable () -> Unit) { fun ScreenEntry(shell: ShellState, route: String, active: Boolean, content: @Composable () -> Unit) {
remember(shell, shell.person.key, route) { shell.prepareDestinationView(route) } 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. */ /** Direct surface previews use the current view when no navigation entry is installed. */

View file

@ -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<ComponentActivity>()
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<DiscoverRequestResult>()
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<String, ImageBitmap>()
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)
}
}

View file

@ -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<ComponentActivity>()
@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<ActivityScenarioRule<ComponentActivity>, 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<ActivityScenarioRule<ComponentActivity>, 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<Bitmap> = 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<Int>, 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))
}
}
}

View file

@ -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<ComponentActivity>()
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<String?>("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()
}
}