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)
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:
commit
844530404d
36 changed files with 875 additions and 97 deletions
|
|
@ -282,6 +282,11 @@
|
|||
"dp": 380.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"toastPaddingY": { "$type": "dimension", "px": 22, "dp": 11, "source": "locked index.html:270" },
|
||||
"toastGap": { "$type": "dimension", "px": 14, "dp": 7, "source": "locked index.html:270" },
|
||||
"toastShadowBlur": { "$type": "dimension", "px": 60, "dp": 30, "source": "locked index.html:270" },
|
||||
"toastShadowY": { "$type": "dimension", "px": 20, "dp": 10, "source": "locked index.html:270" },
|
||||
"toastShadowOpacity": { "$type": "number", "$value": 0.5, "source": "locked index.html:270" },
|
||||
"cardWashInset": {
|
||||
"$type": "dimension",
|
||||
"px": 30,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)) }
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -59,3 +59,4 @@ fun LibraryPoster(
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -420,6 +420,10 @@
|
|||
--home-toastPadding: 28.0px;
|
||||
--home-toastTop: 150.0px;
|
||||
--home-toastWidth: 760.0px;
|
||||
--home-toastPaddingY: 22.0px;
|
||||
--home-toastGap: 14.0px;
|
||||
--home-toastShadowBlur: 60.0px;
|
||||
--home-toastShadowY: 20.0px;
|
||||
--home-cardWashInset: 30.0px;
|
||||
--home-heroWashInset: 80.0px;
|
||||
--home-noteTop: 14.0px;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json
|
||||
85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py
|
||||
9421e7b073f8eb9d170c7011eba62a0cbc4700c94ceb9ad2cc53966a718ee1d5 home-tokens.json
|
||||
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
|
||||
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json
|
||||
ccdd0134fc2ea069c3d8bd7717657d53544fe8393696edc282bb40e142069e97 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
1c805ae8eacbca1c63fbf91e19d7f9fea6c27b54e709e91f6bd46720d5048a41 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
7ed093cc63b0fd6f47fe951da5df26ee7258865b8d0a51345a408ab64f037de0 title-tokens.json
|
||||
8b351ce603c6ca39c72d41e012831ef84c082f51132b53dd45b3068ad2dc68b2 tokens.css
|
||||
3b14b60264e1f97c02be3e57d0c0bd9477e982044ee944c8c8a5b71e939fa8fb tokens.css
|
||||
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue