Integrate shared offline recovery and saved-state isolation
All checks were successful
review/design-claude 13 fixes passed Sonnet re-review at cd625b42; merged head 84453040 = main merge + shared RecoveryToast reuse: 33/34 pairs byte-identical, st-048 adopts the shared toast, 830 tests green (firstmate check)

This commit is contained in:
justinzeus 2026-11-05 22:59:45 +01:00
commit 844530404d
36 changed files with 875 additions and 97 deletions

View file

@ -81,27 +81,30 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
}
data.items.isEmpty() -> Text("Nothing coming in.", Modifier.padding(top = T.emptyMargin), style = ComingInTypography.body, color = C.muted)
else -> {
if (data.status == ComingInStatus.Unavailable) Text("Conductarr didn’t answer.", style = ComingInTypography.caption, color = C.warn)
data.savedAt?.takeIf { data.status == ComingInStatus.Unavailable }?.let { SavedPill(it) }
if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn)
val start = state.windowStart
val visibleRows = remember(data, start, state.windowSize) {
data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size))
}
LazyColumn(Modifier.clipToBounds(), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
itemsIndexed(visibleRows,
key = { _, item -> item.id }, contentType = { _, _ -> "request" }) { _, item ->
ComingInRow(item, shell.person, item.id == state.focusedId, state.expanded && item.id == state.focusedId,
ComingInRow(item, shell.person, !state.recoveryFocused && item.id == state.focusedId, state.expanded && item.id == state.focusedId,
state.ratingsVisible && data.signedIn && item.id == state.focusedId,
onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() })
}
}
if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = state.recoveryFocused) { shell.retryRequests() }
}
}
}
}
@Composable
private fun SheetButton(label: String, primary: Boolean = false, onClick: () -> Unit) {
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, true, primary, onClick = onClick) }
private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, onClick: () -> Unit) {
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = onClick) }
}
@Composable

View file

@ -51,6 +51,7 @@ data class ComingInSnapshot(
val status: ComingInStatus = ComingInStatus.Unavailable,
val partial: Boolean = false,
val signedIn: Boolean = false,
val savedAt: String? = null,
) {
val ordered = items.sortedBy { it.stage.rank }
val openCount: Int get() = items.count { it.stage != ComingInStage.Ready }
@ -66,6 +67,8 @@ class ComingInState {
private set
var ratingsVisible by mutableStateOf(false)
private set
var recoveryFocused by mutableStateOf(false)
private set
private val cachedRatings = mutableSetOf<String>()
val index: Int get() = snapshot.ordered.indexOfFirst { it.id == focusedId }.coerceAtLeast(0)
val focused: ComingInRequest? get() = snapshot.ordered.getOrNull(index)
@ -73,14 +76,18 @@ class ComingInState {
val windowStart: Int get() = (index - 2).coerceIn(0, (snapshot.ordered.size - windowSize).coerceAtLeast(0))
fun update(value: ComingInSnapshot) {
snapshot = value
if (!value.signedIn) cachedRatings.clear()
if (value.ordered.none { it.id == focusedId }) focus(value.ordered.firstOrNull()?.id)
ratingsVisible = value.signedIn && focused?.ratingKey in cachedRatings
snapshot = if (value.status != ComingInStatus.Ready && value.signedIn && value.items.isEmpty() && snapshot.signedIn) {
snapshot.copy(status = value.status)
} else value
if (!snapshot.signedIn) cachedRatings.clear()
if (snapshot.ordered.none { it.id == focusedId }) focus(snapshot.ordered.firstOrNull()?.id)
ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings
if (snapshot.status == ComingInStatus.Ready) recoveryFocused = false
}
fun focus(id: String?) {
focusedId = id
recoveryFocused = false
expanded = false
ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings
}
@ -97,9 +104,14 @@ class ComingInState {
/** Return true only when a row requests a title; every direction remains trapped in the sheet. */
fun key(key: ShellKey): Boolean {
if (recoveryFocused) {
if (key == ShellKey.Up) recoveryFocused = false
return false
}
when (key) {
ShellKey.Up -> focus(snapshot.ordered.getOrNull((index - 1).coerceAtLeast(0))?.id)
ShellKey.Down -> focus(snapshot.ordered.getOrNull((index + 1).coerceAtMost(snapshot.ordered.lastIndex))?.id)
ShellKey.Down -> if (snapshot.status == ComingInStatus.Unavailable && index == snapshot.ordered.lastIndex) recoveryFocused = true
else focus(snapshot.ordered.getOrNull((index + 1).coerceAtMost(snapshot.ordered.lastIndex))?.id)
ShellKey.Right -> if (focused != null) expanded = true
ShellKey.Left -> expanded = false
ShellKey.Ok -> return focused != null

View file

@ -14,7 +14,6 @@ import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
internal data class CssLineBox(val lineHeight: TextUnit, val top: Dp, val bottom: Dp) : LayoutModifier, ParentDataModifier {
@ -41,7 +40,7 @@ private class CssColumnHeight : ParentDataModifier {
/** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */
@Composable
internal fun CssColumn(modifier: Modifier = Modifier, gap: Dp = 0.dp, content: @Composable () -> Unit) {
internal fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) {
val exact = remember { CssColumnHeight() }
Layout(content, modifier.then(exact)) { children, constraints ->
val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) }

View file

@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import coil3.compose.AsyncImage
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
@ -40,7 +41,7 @@ val LocalDiscoverArt = compositionLocalOf<((String) -> ImageBitmap?)?> { null }
/** Decode requests use the actual art slot, with RGB565 only for upright posters. */
@Composable
fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false, dim: Boolean = false) {
fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false, dim: Boolean = false, onError: () -> Unit = {}) {
if (path == null) return
val bitmap = LocalDiscoverArt.current?.invoke(path)
val painter = remember(bitmap) { bitmap?.let(::BitmapPainter) }
@ -57,18 +58,21 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
model = request,
imageLoader = WolfskinImages.loader(context),
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = filter,
onError = { onError() },
)
}
/** Poster-only titles retain the one-sheet over a blurred wash rather than stretching the poster. */
@Composable
fun DiscoverBackdrop(title: DiscoverTitle, detail: Boolean = false, modifier: Modifier = Modifier) {
val failed = remember(title.poster, title.backdrop) { androidx.compose.runtime.mutableStateOf(false) }
Box(modifier.size(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).clipToBounds().background(C.surface3)) {
if (title.posterOnly) {
if (failed.value || (title.poster == null && title.backdrop == null)) HomeAmbient(Modifier.fillMaxSize().testTag("discover-missing-art"))
else if (title.posterOnly) {
DiscoverArtwork(title.poster, Modifier.offset(-D.ambientInset, -D.ambientInset)
.wrapContentSize(Alignment.TopStart, unbounded = true)
.size(WolfskinTokens.Shell.stageWidth + D.ambientInset * 2, WolfskinTokens.Shell.stageHeight + D.ambientInset * 2)
.graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale }.blur(D.ambientBlur), dim = true)
.graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale }.blur(D.ambientBlur), dim = true, onError = { failed.value = true })
DiscoverArtwork(title.poster, Modifier.offset(
WolfskinTokens.Shell.stageWidth - D.heroSheetRight - if (detail) L.heroSheetWidth else D.heroSheetWidth,
if (detail) L.navBand else D.heroSheetTop,
@ -76,8 +80,8 @@ fun DiscoverBackdrop(title: DiscoverTitle, detail: Boolean = false, modifier: Mo
.dropShadow(RoundedCornerShape(WolfskinTokens.Radius.heroSheet), Shadow(
radius = WolfskinTokens.Lift.heroBlur, color = Color.Black.copy(alpha = WolfskinTokens.Lift.heroOpacity),
offset = DpOffset(WolfskinTokens.Shell.zero, WolfskinTokens.Lift.heroY)))
.clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet)), poster = true)
} else DiscoverArtwork(title.backdrop ?: title.poster, Modifier.fillMaxSize())
.clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet)), poster = true, onError = { failed.value = true })
} else DiscoverArtwork(title.backdrop ?: title.poster, Modifier.fillMaxSize(), onError = { failed.value = true })
Box(Modifier.fillMaxSize().drawWithCache {
val scrim = Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f), endX = size.width)
onDrawBehind { drawRect(scrim) }

View file

@ -67,11 +67,7 @@ fun DiscoverOverlay(state: DiscoverState) {
}
}
}
state.toast?.let { text ->
Box(Modifier.offset(WolfskinTokens.Shell.stageWidth - L.safeSide - WolfskinTokens.Library.captionWidth, L.navBand).width(WolfskinTokens.Library.captionWidth).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(L.buttonGap)) {
Text(text, color = C.fg, style = DiscoverType.body)
}
}
state.toast?.let { RecoveryToast(it) }
}
@Composable

View file

@ -46,11 +46,12 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
shell.contentFocus = focus
onDispose { if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false } }
}
LaunchedEffect(shell.route, state.snapshot.savedAt) { shell.view.savedAt = state.snapshot.savedAt }
LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } }
val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column)
LaunchedEffect(focusedTitle?.key, state.contentFocused) {
LaunchedEffect(focusedTitle?.key, state.contentFocused, state.snapshot.savedAt) {
state.plateKey = null
if (state.contentFocused && focusedTitle != null) {
if (state.contentFocused && focusedTitle != null && (state.snapshot.savedAt == null || focusedTitle.key in state.observedRatings)) {
if (focusedTitle.key !in state.observedRatings) kotlinx.coroutines.delay(WolfskinTokens.Motion.ratingDwell.toLong())
state.observedRatings.add(focusedTitle.key); state.plateKey = focusedTitle.key
}
@ -68,11 +69,11 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
val density = LocalDensity.current
val list = shell.view.listState
val note = state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)
val heights = remember(state.hasHero, state.lanes, note, state.canRequest) {
val heights = remember(state.hasHero, state.lanes, note, state.hasCatalogue) {
buildList {
add(if (state.hasHero) L.row_phero else L.row_lhead)
add(if (state.canRequest) L.row_chips else WolfskinTokens.Shell.zero)
if (!state.canRequest) add(L.row_state) else {
add(if (state.hasCatalogue) L.row_chips else WolfskinTokens.Shell.zero)
if (!state.hasCatalogue) add(L.row_state) else {
if (note) add(L.row_note)
state.lanes.forEach { lane -> add(L.rowHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) }
}

View file

@ -19,9 +19,11 @@ class DiscoverState(
private val onPush: (String) -> Unit = {},
private val onPop: () -> Unit = {},
private val serviceLanes: (DiscoverService, String) -> List<DiscoverLane> = { _, _ -> emptyList() },
private val nowMillis: () -> Long = System::currentTimeMillis,
) : ShellContentFocus {
var snapshot by mutableStateOf(initial)
private set
private var savedTime = initial.savedAt ?: formatSavedTime()
private var serviceFilter by mutableStateOf("movie")
var filter: String
get() = if (service != null) serviceFilter else DISCOVER_FILTERS[shell.rememberedView("discover").cards["filter"] ?: 0]
@ -59,12 +61,14 @@ class DiscoverState(
private val dismissalVersions = mutableMapOf<String, Int>()
private var rootFilter = "all"
override val hasContent: Boolean get() = snapshot.status != DiscoverStatus.Loading
val isSignedIn: Boolean get() = snapshot.status != DiscoverStatus.NeedsSignIn
val canRequest: Boolean get() = when (snapshot.status) {
val hasCatalogue: Boolean get() = when (snapshot.status) {
DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable -> true
else -> false
}
val hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && canRequest
val canRequest: Boolean get() = hasCatalogue && snapshot.savedAt == null
val hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && hasCatalogue
val heroes: List<DiscoverTitle> get() {
if (cachedHeroesRevision != revision || cachedHeroesFilter != filter) {
val source = if (snapshot.status == DiscoverStatus.Ready) snapshot.picks else snapshot.lanes.firstOrNull { it.id == "trending" }?.titles.orEmpty()
@ -127,8 +131,9 @@ class DiscoverState(
grid = LibraryGridState(lane.title, collectionCards.values.filter { it.id !in hidden }, catalogue = true, observedRatings = observedRatings,
onTitle = { openTitle(titles.getValue(it.id)) }, onDiscover = { collection = null; onPop() }, onRecover = {
scope.launch {
snapshot = operations.refresh(); revision++
reload()
if (collection !== grid) return@launch
grid.savedAt = snapshot.savedAt
val fresh = lanes.firstOrNull { it.id == lane.id }
if (canRequest && fresh != null) {
titles = fresh.fullTitles.associateBy { it.key }
@ -140,6 +145,7 @@ class DiscoverState(
} else grid.tail = GridTail.Retry
}
}).apply {
savedAt = snapshot.savedAt
tail = when (lane.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None }
}
collection = grid
@ -185,7 +191,7 @@ class DiscoverState(
return true
}
if (title != null) { titleKey(key); return true }
if (!canRequest) {
if (!hasCatalogue) {
if (key == ShellKey.Up) shell.focusNav(shell.currentTab) else if (key == ShellKey.Ok) refresh()
return true
}
@ -254,8 +260,8 @@ class DiscoverState(
when (val sheet = overlay) {
is DiscoverOverlayState.Request -> sheet.state.key(key, ::send, { overlay = null }) {
scope.launch {
snapshot = operations.refresh(); revision++
sheet.state.phase = if (snapshot.status == DiscoverStatus.Ready) RequestPhase.Choose else RequestPhase.RecipientsUnavailable
reload()
sheet.state.phase = if (canRequest) RequestPhase.Choose else RequestPhase.RecipientsUnavailable
sheet.state.focus = sheet.state.buttonIndex
}
}
@ -326,12 +332,25 @@ class DiscoverState(
}
}
fun refresh() { scope.launch { snapshot = operations.refresh(); revision++; toast = if (snapshot.status == DiscoverStatus.Unavailable) "Still can’t reach it. The last saved view stays on screen." else null } }
private fun matches(item: DiscoverTitle): Boolean = filter == "all" || item.type == filter
fun refresh() { scope.launch { reload() } }
private fun buildLanes(): List<DiscoverLane> = service?.let {
serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() }
} ?: projectDiscoverLanes(snapshot, heroes, filter, hidden)
private suspend fun reload() {
val refreshed = operations.refresh()
val failed = refreshed.status == DiscoverStatus.Unavailable
snapshot = if (failed && hasCatalogue) snapshot.copy(savedAt = savedTime) else refreshed
if (!failed && hasCatalogue) savedTime = formatSavedTime()
revision++
toast = if (failed) RecoveryMessage.StillUnreachable else null
}
private fun matches(item: DiscoverTitle): Boolean = filter == "all" || item.type == filter
private fun formatSavedTime(): String = java.time.Instant.ofEpochMilli(nowMillis()).atZone(java.time.ZoneId.systemDefault()).format(java.time.format.DateTimeFormatter.ofPattern("HH:mm"))
private fun buildLanes(): List<DiscoverLane> {
val lanes = service?.let {
serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() }
} ?: projectDiscoverLanes(snapshot, heroes, filter, hidden)
return if (snapshot.savedAt != null) lanes + DiscoverLane("offline-recovery", "Try again", emptyList(), tail = DiscoverTail.Retry) else lanes
}
}
private fun Int?.orZero(): Int = this ?: 0

View file

@ -9,6 +9,7 @@ class HomeFocus(
private val onAction: (HomeAction) -> Unit,
) : ShellContentFocus {
private val view = shell.rememberedView("home")
override val hasContent: Boolean get() = rows.any { it.focusable }
override val cardKey: String? get() = row?.let { if (it.kind == HomeRowKind.Shelf) it.cards.getOrNull(column)?.id ?: it.id else null }
val row: HomeRow? get() = rows.getOrNull(view.row)
val column: Int get() = row?.let { view.cards[it.id] ?: 0 } ?: 0

View file

@ -69,11 +69,7 @@ fun HomeSection(person: ShellPerson, savedAt: String?) {
Text("Discover", color = C.fg, style = T.sectionTitle)
Avatar(person, H.sectionAvatar, T.avatarLetterSectionHeader)
Text("Picks for you first, then what’s out there", color = C.muted, style = T.body)
if (savedAt != null) Box(Modifier.height(WolfskinTokens.Shell.settingsPillHeight)
.border(H.hairline, C.warn.copy(alpha = .45f), RoundedCornerShape(WolfskinTokens.Radius.pill))
.padding(horizontal = WolfskinTokens.Shell.settingsPillPadding + H.hairline), contentAlignment = Alignment.Center) {
Text("Saved $savedAt", color = C.warn, style = T.caption.copy(fontWeight = T.label.fontWeight))
}
savedAt?.let { SavedPill(it) }
}
}
}

View file

@ -104,6 +104,7 @@ class LibraryGridState(
}
val railVisible: Boolean get() = !catalogue && sort == 0 && items.size >= WolfskinTokens.Behaviour.letterRailMin && status == LibraryStatus.Ready
val tailIndex: Int get() = ((items.size + columns - 1) / columns) * columns
override val hasContent: Boolean get() = status != LibraryStatus.Loading
override val cardKey: String? get() = if (toolbar || inRail || status != LibraryStatus.Ready || items.isEmpty()) null else items.getOrNull(index)?.id ?: "grid-tail"
val focusedTitle: LibraryTitle? get() = if (toolbar || inRail) null else items.getOrNull(index)

View file

@ -59,3 +59,4 @@ fun LibraryPoster(
}
}

View file

@ -76,6 +76,7 @@ fun LibraryScreen(
plateKey = focused.id
}
}
LaunchedEffect(grid.savedAt) { view.savedAt = grid.savedAt }
LaunchedEffect(grid.status) {
if (grid.status == LibraryStatus.Loading) shell.focusNav(shell.currentTab)
}
@ -169,7 +170,7 @@ fun LibraryHeader(person: ShellPerson, title: String, subtitle: String = "", sav
Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted)
Text(title, modifier = Modifier.alignByBaseline(), color = C.fg, style = WolfskinTokens.Type.screenTitle)
if (subtitle.isNotEmpty()) Text(subtitle, modifier = Modifier.alignByBaseline(), color = C.muted, style = G.headerSubtitle)
savedAt?.let { Text("Saved $it", color = C.warn, style = WolfskinTokens.Type.caption) }
savedAt?.let { SavedPill(it) }
}
}
}

View file

@ -33,7 +33,6 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
import org.justinvisser.wolfskin.design.WolfskinTokens.SavedIndicator as V
@ -57,7 +56,7 @@ fun navBoxes(tabs: List<String>): List<NavBox> {
/** There is one blob, including when focus wraps around the nav avatar. */
@Composable
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier, savedAt: String? = null) {
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
val labelFade = remember { tween<Color>(S.navLabelFade, easing = CubicBezierEasing(0f, 0f, .58f, 1f)) }
val index = if (state.navFocused) state.navIndex else state.currentTab
@ -101,7 +100,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier,
}
}
}
(savedAt ?: state.view.savedAt)?.let { time ->
state.navSavedAt?.let { time ->
Row(Modifier.align(Alignment.TopEnd)
.offset(x = -(L.safeSide + L.navAvatar + L.navItemGap), y = S.navItemTop + (L.navAvatar - V.height) / 2)
.height(V.height).border(V.border, C.warn.copy(alpha = V.borderOpacity), RoundedCornerShape(WolfskinTokens.Radius.button))

View file

@ -86,11 +86,13 @@ class PersonState(initialPerson: TitlePerson, initialCredits: List<TitleItem>, p
/** Portrait, biography and the full continuous grid use the shell's remembered frame. */
@Composable
fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
val view = shell.view
LaunchedEffect(view, state.savedAt) { view.savedAt = state.savedAt }
val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } }
LaunchedEffect(state) { shell.navFocused = false }
Box(modifier.fillMaxSize().background(C.bg)) {
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) {
ShellSurface(shell, handlesContentKeys = false, savedAt = state.savedAt) { PersonPage(state, shell, it) }
ShellSurface(shell, handlesContentKeys = false) { PersonPage(state, shell, it) }
}
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
}

View file

@ -0,0 +1,43 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.DpOffset
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
/** Recovery messages remain transient and never take focus from the action that failed. */
@Composable
fun RecoveryToast(message: String, modifier: Modifier = Modifier) {
Box(modifier.fillMaxSize()) {
val shape = androidx.compose.runtime.remember { RoundedCornerShape(WolfskinTokens.Radius.panel) }
Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, H.toastTop)
.widthIn(max = H.toastWidth)
.dropShadow(shape, Shadow(radius = H.toastShadowBlur, color = Color.Black.copy(alpha = H.toastShadowOpacity),
offset = DpOffset(WolfskinTokens.Shell.zero, H.toastShadowY)))
.background(C.surface2, shape)
.testTag("recovery-toast")
.padding(horizontal = H.toastPadding, vertical = H.toastPaddingY),
horizontalArrangement = Arrangement.spacedBy(H.toastGap), verticalAlignment = Alignment.CenterVertically) {
Glyph("alert", C.warn, Modifier.size(L.buttonIcon))
Text(message, Modifier.weight(1f, fill = false).exactLineBox(HomeType.body), color = C.fg, style = HomeType.body)
}
}
}
/** Shared wording is independent of transport details and never exposes exception text. */
object RecoveryMessage {
const val StillUnreachable = "Still can’t reach it. The last saved view stays on screen."
const val PlaybackUnavailable = "Can’t reach Jellyfin to play. Check the network and try again."
}

View file

@ -0,0 +1,25 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
/** Headers identify a retained copy without becoming a separate focus target. */
@Composable
fun SavedPill(time: String, modifier: Modifier = Modifier) {
Box(modifier.height(S.settingsPillHeight)
.border(H.hairline, C.warn.copy(alpha = .45f), RoundedCornerShape(WolfskinTokens.Radius.pill))
.padding(horizontal = S.settingsPillPadding + H.hairline), contentAlignment = Alignment.Center) {
Text("Saved $time", color = C.warn, style = HomeType.caption.copy(fontWeight = HomeType.label.fontWeight))
}
}

View file

@ -26,6 +26,7 @@ enum class PeopleStatus { Ready, Loading, Unavailable }
/** Screens participate in the shell's hold guard and Back ladder without a second key adapter. */
interface ShellContentFocus {
val hasContent: Boolean get() = true
val cardKey: String?
fun key(key: ShellKey): Boolean
}
@ -59,7 +60,9 @@ class ShellState(
var peopleStatus by mutableStateOf(PeopleStatus.Ready)
var recoveryIndex by mutableStateOf(0)
var requestsVisible by mutableStateOf(false)
val requests = ComingInState()
var toast by mutableStateOf<String?>(null)
private val personRequests = mutableMapOf<String, ComingInState>()
val requests: ComingInState get() = personRequests.getOrPut(person.key) { ComingInState() }
private var requestReturnRoute: String? = null
var handOff by mutableStateOf<String?>(null)
var settingsRow by mutableStateOf(0)
@ -74,6 +77,7 @@ class ShellState(
val tabs: List<String> get() = if (person.hasAnime) ANIME_TABS else BASE_TABS
val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0)
val view: RememberedView get() = rememberedView(route)
val navSavedAt: String? get() = if (requestsVisible && requests.snapshot.status == ComingInStatus.Unavailable) requests.snapshot.savedAt ?: view.savedAt else view.savedAt
/** Screen options share the same per-person memory as their root's focus and scroller. */
fun rememberedView(destination: String): RememberedView =
@ -118,7 +122,7 @@ class ShellState(
if (requestsVisible) {
when {
key == ShellKey.Back -> closeRequests()
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Unavailable && requests.snapshot.items.isEmpty() -> retryRequests()
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Unavailable && (requests.snapshot.items.isEmpty() || requests.recoveryFocused) -> retryRequests()
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Loading && requests.snapshot.items.isEmpty() -> closeRequests()
requests.key(key) -> activateRequest()
}
@ -184,7 +188,7 @@ class ShellState(
when (key) {
ShellKey.Left -> navIndex = (navIndex + tabs.size) % (tabs.size + 1)
ShellKey.Right -> navIndex = (navIndex + 1) % (tabs.size + 1)
ShellKey.Down -> navFocused = false
ShellKey.Down -> if (contentFocus?.hasContent != false) navFocused = false
ShellKey.Ok -> activate()
else -> Unit
}

View file

@ -56,13 +56,19 @@ fun ShellSurface(
modifier: Modifier = Modifier,
handlesContentKeys: Boolean = true,
settingsOpenRequests: Int? = null,
savedAt: String? = null,
overlay: @Composable () -> Unit = {},
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
) {
val focus = remember { FocusRequester() }
val contentFocus = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
LaunchedEffect(state.toast) {
val message = state.toast
if (message != null) {
kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong())
if (state.toast == message) state.toast = null
}
}
LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) {
if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus()
else contentFocus.requestFocus()
@ -86,10 +92,11 @@ fun ShellSurface(
WhosWatching(state)
} else {
if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests ?: state.requests.snapshot.openCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2, savedAt = savedAt)
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
if (state.requestsVisible) ComingInSheet(state)
overlay()
}
state.toast?.let { RecoveryToast(it) }
}
}

View file

@ -4,8 +4,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
/** Explicit CSS leading and margins share one fractional layout engine across Wolfskin screens. */
fun Modifier.exactLineBox(style: TextStyle, top: Dp = 0.dp, bottom: Dp = 0.dp): Modifier =
fun Modifier.exactLineBox(style: TextStyle, top: Dp = WolfskinTokens.Shell.zero, bottom: Dp = WolfskinTokens.Shell.zero): Modifier =
if (style.lineHeight == TextUnit.Unspecified) this else then(CssLineBox(style.lineHeight, top, bottom))

View file

@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import kotlinx.coroutines.delay
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
@ -52,6 +51,8 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
/** The shared shell remains authoritative; title sheets overlay it and trap all input. */
@Composable
fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
val view = shell.view
LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt }
Box(modifier.fillMaxSize().onPreviewKeyEvent { event ->
// No content target exists during an initial load; nav Down must not enter a skeleton.
if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false
@ -66,7 +67,7 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
}) {
val focusOwner = remember(shell) { { shell.navFocused = false } }
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
ShellSurface(shell, handlesContentKeys = false, savedAt = state.data.savedAt) { TitlePage(state, shell, it) }
ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) }
}
if (!shell.pickerVisible && state.sheet != null) TitleOptionsSheet(state, shell)
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
@ -79,14 +80,7 @@ internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDism
LaunchedEffect(message) {
if (message != null) { delay(WolfskinTokens.Motion.toast.toLong()); onDismiss() }
}
if (message != null) {
Row(modifier.padding(top = L.navBand, end = L.safeSide).widthIn(max = D.toastMaxWidth)
.background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.toastSide, vertical = D.toastTop),
horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
Glyph("alert", C.warn, Modifier.size(L.buttonIcon))
Text(message, color = C.fg, style = WolfskinTokens.Type.body)
}
}
if (message != null) RecoveryToast(message, modifier)
}
/** One keyed vertical frame renders the exact header, season, cast and related order. */

View file

@ -590,6 +590,11 @@ object WolfskinTokens {
val toastPadding = 14.0.dp
val toastTop = 75.0.dp
val toastWidth = 380.0.dp
val toastPaddingY = 11.0.dp
val toastGap = 7.0.dp
val toastShadowBlur = 30.0.dp
val toastShadowY = 10.0.dp
const val toastShadowOpacity = 0.5f
val cardWashInset = 15.0.dp
val heroWashInset = 40.0.dp
val noteTop = 7.0.dp

View file

@ -0,0 +1,197 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import android.app.Application
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.text.TextLayoutResult
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import java.util.Locale
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Before
import org.junit.After
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Native state proof uses only packaged fixtures and records the actual text/font/layout surface. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class RecoveryCaptureTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val previousLocale = Locale.getDefault()
@Before fun fullscreen() {
Locale.setDefault(Locale.US)
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
}
@After fun restoreLocale() { Locale.setDefault(previousLocale) }
private fun shell(route: String = "home") = ShellState(fixturePeople(), "mindel").apply { pickerVisible = false; enterDestination(route) }
private fun mount(shell: ShellState, overlay: @Composable () -> Unit = {}, content: @Composable (Modifier) -> Unit) {
rule.setContent { WolfskinTheme {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), overlay = overlay, content = content)
} }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
}
private fun capture(name: String) {
val directory = File(System.getProperty("wolfskin.captureDirectory"), "recovery").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
val text = JSONArray()
val forbidden = Regex("Recs Movies|Recs Shows|DGiC|(?:Justin|Mindel|Shared) (?:Movies|Shows)|(?:Next|Previous) page|Page [0-9]")
rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
val layouts = mutableListOf<TextLayoutResult>()
node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts)
layouts.forEach { layout ->
val words = layout.layoutInput.text.text
val style = layout.layoutInput.style
assertTrue("Text smaller than 12sp: $words", style.fontSize.value >= 12f)
assertFalse("Internal wording: $words", forbidden.containsMatchIn(words))
text.put(JSONObject().put("text", words).put("fontSizeSp", style.fontSize.value).put("weight", style.fontWeight?.weight)
.put("lineCount", layout.lineCount).put("lineHeightSp", style.lineHeight.value.takeIf { it.isFinite() } ?: JSONObject.NULL)
.put("left", node.boundsInRoot.left).put("top", node.boundsInRoot.top).put("width", node.boundsInRoot.width).put("height", node.boundsInRoot.height))
}
}
File(directory, "$name-layout.json").writeText(text.toString(2))
}
@Test fun st090_offlinePlayToastRetainsHomeAndFocusedResume() {
val shell = shell()
val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline")
shell.toast = RecoveryMessage.PlaybackUnavailable
mount(shell) { HomeScreen(shell, snapshot, it) {} }
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed()
rule.onNodeWithText("Resume S4 E9").assertIsDisplayed()
assertFalse(shell.navFocused)
val toast = rule.onNodeWithTag("recovery-toast").fetchSemanticsNode().boundsInRoot
val message = rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).fetchSemanticsNode().boundsInRoot
assertEquals(1064f, toast.left, 1f)
assertEquals(150f, toast.top, 1f)
assertEquals(760f, toast.width, 1f)
assertEquals(1136f, message.left, 1f)
capture("st-090")
}
@Test fun savedLibraryHasNavAndHeaderChipAndAReachableRetryTail() {
var retried = false
val shell = shell("movies")
val grid = LibraryGridState("Movies", listOf(LibraryTitle("saved", "A saved film", "movie")), onRecover = { retried = true }).apply {
savedAt = "20:42"; tail = GridTail.Retry
}
mount(shell) { LibraryScreen(shell, grid, it) }
assertEquals(2, rule.onAllNodesWithText("Saved 20:42").fetchSemanticsNodes().size)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) }
assertTrue(retried)
capture("library-saved")
}
@Test fun libraryWithoutSavedDataOffersRecovery() {
var retried = false
val shell = shell("shows")
val grid = LibraryGridState("Shows", emptyList(), onRecover = { retried = true }).apply { status = LibraryStatus.Unavailable }
mount(shell) { LibraryScreen(shell, grid, it) }
rule.onNodeWithText("Try again").assertIsDisplayed()
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
assertTrue(retried)
capture("library-unavailable")
}
@Test fun savedDiscoverKeepsHeroAndRecoveryWithoutAllowingRequests() {
val shell = shell("discover")
val snapshot = discoverTestFixture().snapshot("mindel").copy(savedAt = "20:42")
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(DiscoverSnapshot(status = DiscoverStatus.Unavailable), "Mindel"), CoroutineScope(Dispatchers.Main.immediate))
mount(shell, overlay = { DiscoverOverlay(state) }) { DiscoverScreen(state, it) }
rule.onNodeWithText("Saved 20:42").assertIsDisplayed()
assertTrue(state.hasHero)
assertFalse(state.canRequest)
rule.runOnIdle { state.focus(state.rowCount - 1) }
rule.mainClock.advanceTimeBy(1000)
rule.onNodeWithText("More titles couldn’t load").assertIsDisplayed()
capture("discover-saved")
}
@Test fun unavailableDiscoverRetryShowsExactFailureToast() {
val shell = shell("discover")
val snapshot = DiscoverSnapshot(status = DiscoverStatus.Unavailable)
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "Mindel"), CoroutineScope(Dispatchers.Main.immediate))
mount(shell, overlay = { DiscoverOverlay(state) }) { DiscoverScreen(state, it) }
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
rule.onNodeWithText(RecoveryMessage.StillUnreachable).assertIsDisplayed()
capture("discover-unavailable-retry")
}
@Test fun savedRequestsKeepTheirRowsAndRecoveryAfterFailure() {
val shell = shell("settings")
val item = ComingInRequest("saved", "movie", 1, "A saved request", "mindel", "Mindel", "shared", "Shared", ComingInStage.Waiting)
shell.requests.update(ComingInSnapshot(listOf(item), ComingInStatus.Ready, signedIn = true, savedAt = "20:42"))
shell.requests.update(ComingInSnapshot(status = ComingInStatus.Unavailable, signedIn = true))
shell.openRequests()
mount(shell) { SettingsList(shell, it) }
rule.onNodeWithText("A saved request").assertIsDisplayed()
rule.onNodeWithText("Try again").assertIsDisplayed()
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown) }
assertTrue(shell.requests.recoveryFocused)
capture("requests-saved")
}
@Test fun failedPosterReadUsesTheSameMissingArtFallbackAsHome() {
val title = LibraryTitle("missing", "A missing film", "movie", poster = "file:///missing-offline-poster.jpg")
mount(shell("movies")) { LibraryScreen(shell("movies"), LibraryGridState("Movies", listOf(title)), it) }
rule.waitUntil(5000) { rule.onAllNodesWithText(title.title).fetchSemanticsNodes().size >= 2 }
capture("library-missing-art")
}
@Test fun dutchLibraryScoreRendersCommaWithoutBreakingThePlate() {
Locale.setDefault(Locale.forLanguageTag("nl-NL"))
val title = LibraryTitle("dutch", "A film", "movie", ratings = listOf(LibraryRating("tmdb", 7.8)))
val shell = shell("movies")
mount(shell) { LibraryScreen(shell, LibraryGridState("Movies", listOf(title)), it, ratingsSignedIn = true) }
rule.onNodeWithText("7,8").assertIsDisplayed()
capture("library-dutch-score")
}
@Config(qualifiers = "nl-rNL-w960dp-h540dp-xhdpi")
@Test fun dutchDiscoverHeroScoreRendersComma() {
Locale.setDefault(Locale.forLanguageTag("nl-NL"))
val title = DiscoverTitle(1, "movie", "A film", ratings = listOf(DiscoverRating("tmdb", 7.8)))
val snapshot = DiscoverSnapshot(picks = listOf(title), status = DiscoverStatus.Ready)
val shell = shell("discover")
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "Mindel"), CoroutineScope(Dispatchers.Main.immediate))
mount(shell) { DiscoverScreen(state, it) }
rule.onNodeWithText("7,8").assertIsDisplayed()
capture("discover-dutch-score")
}
@Test fun failedDiscoverBackdropUsesTheSharedAmbientFallback() {
val shell = shell("discover")
val title = DiscoverTitle(1, "movie", "A film", backdrop = "file:///missing-offline-backdrop.jpg")
val snapshot = DiscoverSnapshot(picks = listOf(title), status = DiscoverStatus.Ready)
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "Mindel"), CoroutineScope(Dispatchers.Main.immediate))
mount(shell) { DiscoverScreen(state, it) }
rule.waitUntil(5000) { rule.onAllNodesWithTag("discover-missing-art").fetchSemanticsNodes().isNotEmpty() }
rule.onNodeWithTag("discover-missing-art").assertIsDisplayed()
capture("discover-missing-art")
}
}

View file

@ -0,0 +1,117 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import android.app.Application
import kotlinx.coroutines.test.runTest
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Failed refresh, loading focus and identity changes must never discard or leak a saved view. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class RecoveryStateTest {
@Test fun failedDiscoverRefreshKeepsTitlesAndFocusButDisablesWritesUntilRecovery() = runTest {
val title = DiscoverTitle(1, "movie", "Saved film")
val initial = DiscoverSnapshot(picks = listOf(title), status = DiscoverStatus.Ready)
var response = DiscoverSnapshot(status = DiscoverStatus.Unavailable)
var writes = 0
val operations = object : DiscoverOperations {
override suspend fun refresh() = response
override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult { writes++; return DiscoverRequestResult(RequestPhase.Receipt) }
override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed)
override suspend fun hide(profile: String, title: DiscoverTitle): Boolean { writes++; return true }
override suspend fun restore(profile: String, title: DiscoverTitle): Boolean { writes++; return true }
}
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") }
val state = DiscoverState(shell, initial, operations, backgroundScope, nowMillis = { 1000L })
state.focus(0)
state.refresh()
testScheduler.runCurrent()
assertEquals(listOf(title), state.heroes)
assertEquals(0, state.focusedRow)
assertNotNull(state.snapshot.savedAt)
assertFalse(state.canRequest)
assertEquals(RecoveryMessage.StillUnreachable, state.toast)
state.hide(title)
testScheduler.runCurrent()
assertEquals(0, writes)
val recovery = state.lanes.last()
state.focus(state.lanes.indexOf(recovery) + state.firstShelf)
assertEquals(DiscoverTail.Retry, state.focusedLane?.tail)
response = initial
state.key(ShellKey.Ok)
testScheduler.runCurrent()
assertTrue(state.canRequest)
assertNull(state.snapshot.savedAt)
assertNull(state.toast)
}
@Test fun revokedDiscoverIdentityDropsSavedTitlesInsteadOfPaintingThemWhileSignedOut() = runTest {
val initial = discoverTestFixture().snapshot("justin")
val operations = RecordingDiscoverOperations(DiscoverSnapshot(status = DiscoverStatus.NeedsSignIn), "Justin")
val state = DiscoverState(ShellState(fixturePeople(), "justin"), initial, operations, backgroundScope)
state.refresh()
testScheduler.runCurrent()
assertFalse(state.isSignedIn)
assertTrue(state.heroes.isEmpty())
assertTrue(state.lanes.isEmpty())
}
@Test fun DownCannotLeaveNavForSkeletonOnlyHomeLibraryOrDiscover() = runTest {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
val loading = LibraryGridState("Movies", emptyList()).apply { status = LibraryStatus.Loading }
val home = HomeFocus(shell, buildHomeRows(HomeLibrary(), emptyList(), loading = true)) {}
val discover = DiscoverState(shell, DiscoverSnapshot(status = DiscoverStatus.Loading), RecordingDiscoverOperations(DiscoverSnapshot(), "Justin"), backgroundScope)
for (content in listOf(loading, home, discover)) {
shell.contentFocus = content
shell.focusNav(shell.currentTab)
shell.key(ShellKey.Down)
assertTrue("Skeleton-only content must retain nav focus", shell.navFocused)
}
loading.status = LibraryStatus.Unavailable
shell.contentFocus = loading
shell.key(ShellKey.Down)
assertFalse("A recovery button is reachable once loading fails", shell.navFocused)
}
@Test fun requestsKeepSavedRowsOnFailureAndRecoveryNeverOpensTheLastTitle() {
var retried = false
var opened = false
val shell = ShellState(fixturePeople(), "justin") {
if (it == ShellAction.RetryRequests) retried = true
if (it is ShellAction.RequestTitle) opened = true
}.apply { pickerVisible = false; enterDestination("settings") }
val request = ComingInRequest("a", "movie", 1, "Saved film", "justin", "Justin", "justin", "Justin", ComingInStage.Waiting)
shell.requests.update(ComingInSnapshot(listOf(request), ComingInStatus.Ready, signedIn = true, savedAt = "20:42"))
shell.requests.update(ComingInSnapshot(status = ComingInStatus.Unavailable, signedIn = true))
shell.openRequests()
assertEquals(listOf(request), shell.requests.snapshot.items)
assertEquals("20:42", shell.navSavedAt)
shell.key(ShellKey.Down)
assertTrue(shell.requests.recoveryFocused)
shell.key(ShellKey.Ok)
assertTrue(retried)
assertFalse(opened)
shell.key(ShellKey.Up)
assertFalse(shell.requests.recoveryFocused)
shell.key(ShellKey.Back)
assertFalse(shell.requestsVisible)
}
@Test fun switchingPeopleNeverShowsAnotherPersonsCachedRequestsOrSavedChip() {
val shell = ShellState(fixturePeople(), "justin")
val item = ComingInRequest("private", "movie", 1, "Person A film", "justin", "Justin", "justin", "Justin", ComingInStage.Waiting)
shell.requests.update(ComingInSnapshot(listOf(item), ComingInStatus.Ready, signedIn = true, savedAt = "20:42"))
val original = shell.requests
shell.pickerIndex = 1
shell.key(ShellKey.Ok)
shell.openRequests()
assertEquals("mindel", shell.person.key)
assertTrue(shell.requests.snapshot.items.isEmpty())
assertNull(shell.navSavedAt)
assertNotSame(original, shell.requests)
}
}