Candidate 3: merge polish-lane-b
This commit is contained in:
commit
e378f34f49
9 changed files with 658 additions and 49 deletions
|
|
@ -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<DiscoverRating>) {
|
|||
}
|
||||
|
||||
@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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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 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. */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue