Keep catalogue outcomes and recovery consistent across views

This commit is contained in:
justinzeus 2026-11-05 18:32:09 +01:00
parent c1559e2513
commit b1b15b384b
21 changed files with 340 additions and 216 deletions

View file

@ -40,7 +40,7 @@ class ConductarrDiscoverOperations(
}
override suspend fun check(intent: DiscoverRequest): DiscoverRequestResult {
return when (val result = client.read(TvRoutes.requests(intent.title.tmdb, intent.title.mediaType())).last()) {
return when (val result = client.read(TvRoutes.requests(intent.title.tmdb, intent.title.mediaType()), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) {
is ReadResult.Value -> result.saved.value?.requests?.firstOrNull { it.requester == intent.requester }?.outcome() ?: DiscoverRequestResult(RequestPhase.NotConfirmed)
is ReadResult.Failure -> DiscoverRequestResult(RequestPhase.NotConfirmed)
}

View file

@ -107,14 +107,24 @@ The debug-only fixture and local art live in
Conductarr sign-in state. The fixture host cannot construct a network client:
debug request outcomes and dismissals stay in memory. The real mutation adapter
accepts an injected `ConductarrClient` and uses only its typed routes; it is not
wired to a live server. Unknown request outcomes use a title-requests lookup rather
than blindly submitting again. Whole-series requests require every season.
wired to a live server. Unknown request outcomes force a fresh title-requests read
instead of blindly submitting again, including after closing a sheet or choosing
the same uncertain recipient from another chooser. Whole-series requests require every season.
Discover's native capture suite covers its 28 named acceptance states and emits
text-layout metrics beside the PNGs. Data-driven Android key replays cover opening
Discover in 4 presses, requesting for yourself in 7, requesting for Mindel in 9 and
Not interested in 8. Additional regressions exercise held-OK safety, uncertain
receipts, unknown seasons, Dutch decimal formatting and short-row/tail clamping.
receipts, per-recipient outcomes, collection recovery and Undo, unknown seasons,
Dutch decimal formatting and short-row/tail clamping. Discover reuses the Libraries
grid, poster, ratings plate, See all and recovery cards, bounded image loader, caption
separator token and the shell's single hold guard. Both frames share the 180 ms scroll tween.
The TV keeps platform Roboto with the approved type sizes and weights. Browser
system-font wrapping is not a per-title contract: no bundled font, forced title
breaks or width compensation is applied. Native text-layout measurements accompany
each acceptance capture; font-driven wrapping and text-block positions must be
listed explicitly in its comparison report.
Native Roborazzi captures use Robolectric SDK 34 and
`w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). `make test` writes capture PNGs and

View file

@ -198,30 +198,6 @@
"dp": 12,
"source": "locked index.html:198"
},
"captionWidth": {
"$type": "dimension",
"px": 640,
"dp": 320,
"source": "locked index.html:1242"
},
"plateTop": {
"$type": "dimension",
"px": 14,
"dp": 7,
"source": "locked index.html:162"
},
"plateSide": {
"$type": "dimension",
"px": 16,
"dp": 8,
"source": "locked index.html:162"
},
"plateFade": {
"$type": "dimension",
"px": 46,
"dp": 23,
"source": "locked index.html:162"
},
"noteTop": {
"$type": "dimension",
"px": 14,
@ -240,29 +216,6 @@
"dp": 12,
"source": "locked index.html:202"
},
"captionFactsGap": {
"$type": "dimension",
"px": 18,
"dp": 9,
"source": "locked index.html:154"
},
"captionSeparatorOpacity": {
"$type": "number",
"$value": 0.4,
"source": "locked index.html:1207"
},
"badgeRing": {
"$type": "dimension",
"px": 3,
"dp": 1.5,
"source": "locked index.html:144"
},
"badgeIcon": {
"$type": "dimension",
"px": 28,
"dp": 14,
"source": "locked index.html:142"
},
"entryPaddingX": {
"$type": "dimension",
"px": 22,
@ -305,39 +258,15 @@
"dp": 17,
"source": "locked index.html:1851"
},
"tailGap": {
"$type": "dimension",
"px": 10,
"dp": 5,
"source": "locked index.html:299"
},
"tailIcon": {
"$type": "dimension",
"px": 44,
"dp": 22,
"source": "locked index.html:300"
},
"seeAllGap": {
"$type": "dimension",
"px": 8,
"dp": 4,
"source": "locked index.html:146"
},
"tailDescription": {
"$type": "typography",
"px": 24,
"sp": 12,
"weight": 500,
"$value": {
"fontSize": "12sp",
"fontWeight": 500
},
"source": "locked index.html:301"
},
"sheetSubTop": {"$type": "dimension", "px": -8, "dp": -4, "source": "locked index.html:229"},
"sheetRatingTop": {"$type": "dimension", "px": -10, "dp": -5, "source": "locked index.html:168"},
"sheetRatingBottom": {"$type": "dimension", "px": 6, "dp": 3, "source": "locked index.html:168"},
"sheetLabelTop": {"$type": "dimension", "px": 8, "dp": 4, "source": "locked index.html:230"},
"noteIcon": {"$type": "dimension", "px": 30, "dp": 15, "source": "locked index.html:192"},
"noteGap": {"$type": "dimension", "px": 16, "dp": 8, "source": "locked index.html:191"},
"frameBottomMin": {"$type": "dimension", "px": 80, "dp": 40, "source": "locked index.html:1042"},
"anchorFitTop": {"$type": "dimension", "px": 700, "dp": 350, "source": "locked index.html:1062"},
"anchorBottomClearance": {"$type": "dimension", "px": 40, "dp": 20, "source": "locked index.html:1062"},
"ambientInset": {"$type": "dimension", "px": 80, "dp": 40, "source": "locked index.html:96"},
"ambientBlur": {"$type": "dimension", "px": 60, "dp": 30, "source": "locked index.html:96"},
"ambientScale": {"$type": "number", "$value": 1.1, "source": "locked index.html:96"},

View file

@ -353,6 +353,10 @@
"tailIcon": {"$type":"dimension","px":44,"dp":22,"source":"index.html:300"},
"tailPadding": {"$type":"dimension","px":20,"dp":10,"source":"index.html:299"},
"tailGap": {"$type":"dimension","px":10,"dp":5,"source":"index.html:299"},
"badgeRing": {"$type":"dimension","px":3,"dp":1.5,"source":"index.html:144"},
"badgeIcon": {"$type":"dimension","px":28,"dp":14,"source":"index.html:142"},
"seeAllGap": {"$type":"dimension","px":8,"dp":4,"source":"index.html:146"},
"tailDescription": {"$type":"typography","px":24,"sp":12,"weight":500,"$value":{"fontWeight":500},"source":"index.html:301"},
"imageReads": {"$type":"number","$value":4,"source":"spec 4.9, tokens.json image.concurrentReads"},
"unknownCriticOpacity": {"$type":"number","$value":0.55,"source":"spec 4.7, index.html:161"},
"fallbackPadding": {"$type":"dimension","px":22,"dp":11,"source":"index.html:130"},

View file

@ -0,0 +1,34 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.text.style.TextAlign
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Library as G
/** Shelves and continuous grids share the same recoverable tail instead of a page selector. */
@Composable
fun CatalogueRecoveryCard(retry: Boolean, focused: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
Column(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
.background(C.surface, shape).border(G.borderWidth, C.border, shape).clickable(onClick = onClick).padding(G.tailPadding),
verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon))
Text(if (retry) "Try again" else "Show more", color = C.fg, style = WolfskinTokens.Type.button, textAlign = TextAlign.Center)
Text(if (retry) "More titles couldn’t load" else "Only titles already shown came back", color = C.muted,
style = G.tailDescription, textAlign = TextAlign.Center)
}
}

View file

@ -59,7 +59,7 @@ fun DiscoverOverlay(state: DiscoverState) {
}
}
state.toast?.let { text ->
Box(Modifier.offset(WolfskinTokens.Shell.stageWidth - L.safeSide - D.captionWidth, L.navBand).width(D.captionWidth).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(L.buttonGap)) {
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 = WolfskinTokens.Type.body)
}
}

View file

@ -3,21 +3,18 @@ package org.justinvisser.wolfskin.design
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.tv.material3.Text
import java.util.Locale
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
@ -26,47 +23,29 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
fun formatDiscoverRating(rating: DiscoverRating, locale: Locale): String =
if (rating.source.startsWith("rt")) "${rating.score.toInt()}%" else String.format(locale, "%.1f", rating.score)
/** Full rows retain every observed score; plates shrink then remove only trailing entries. */
/** Heroes, title destinations and sheets show every observed score without a dwell or truncation. */
@Composable
fun DiscoverRatings(ratings: List<DiscoverRating>, modifier: Modifier = Modifier, plate: Boolean = false) {
fun DiscoverRatings(ratings: List<DiscoverRating>, modifier: Modifier = Modifier) {
if (ratings.isEmpty()) return
val locale = LocalConfiguration.current.locales[0]
val labels = remember(ratings, locale) { ratings.map { formatDiscoverRating(it, locale) } }
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
BoxWithConstraints(modifier) {
val available = with(density) { maxWidth.toPx() }
val base = WolfskinTokens.Type.ratingPlate
var font = base.fontSize.value
var count = ratings.size
fun width(): Float {
val style = base.copy(fontSize = TextUnit(font, TextUnitType.Sp))
return (0 until count).sumOf { index ->
val mark = if (ratings[index].source in setOf("imdb", "tmdb")) 1.8f else 1f
(measurer.measure(labels[index], style).size.width + with(density) { style.fontSize.toPx() } * (mark + if (plate) .29f else .2857143f)).toDouble()
}.toFloat() + (count - 1) * with(density) { style.fontSize.toPx() } * (if (plate) .64f else .7142857f)
}
if (plate) {
while (font > WolfskinTokens.Type.caption.fontSize.value && width() > available) font = (font - .25f).coerceAtLeast(WolfskinTokens.Type.caption.fontSize.value)
while (count > 1 && width() > available) count--
}
val style = base.copy(fontSize = TextUnit(font, TextUnitType.Sp), fontFeatureSettings = "tnum")
val markSize = with(density) { style.fontSize.toDp() }
Row(horizontalArrangement = Arrangement.spacedBy(markSize * if (plate) .64f else .7142857f)) {
repeat(count) { index ->
val rating = ratings[index]
val resource = when (rating.source) {
"rtCritics" -> if (rating.label == "Rotten") R.drawable.ic_rotten_tomatoes_rotten else R.drawable.ic_rotten_tomatoes_fresh
"rtAudience" -> if (rating.label == "Spilled") R.drawable.ic_rt_spilled else R.drawable.ic_rt_upright
"imdb" -> R.drawable.ic_rating_imdb
else -> R.drawable.ic_rating_tmdb
}
Row(horizontalArrangement = Arrangement.spacedBy(markSize * if (plate) .29f else .2857143f)) {
val muted = rating.source == "rtCritics" && rating.label !in setOf("Rotten", "Fresh", "Certified Fresh")
Image(painterResource(resource), null, Modifier.height(markSize).width(markSize * if (rating.source in setOf("imdb", "tmdb")) 1.8f else 1f), alpha = if (muted) .55f else 1f,
colorFilter = if (muted) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null)
Text(labels[index], color = C.fg, style = style, maxLines = 1)
}
val style = remember { WolfskinTokens.Type.ratingPlate.copy(fontFeatureSettings = "tnum") }
val markSize = with(LocalDensity.current) { style.fontSize.toDp() }
val desaturated = remember { ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) }
Row(modifier, horizontalArrangement = Arrangement.spacedBy(markSize * .7142857f), verticalAlignment = Alignment.CenterVertically) {
ratings.forEachIndexed { index, rating ->
val resource = when (rating.source) {
"rtCritics" -> if (rating.label == "Rotten") R.drawable.ic_rotten_tomatoes_rotten else R.drawable.ic_rotten_tomatoes_fresh
"rtAudience" -> if (rating.label == "Spilled") R.drawable.ic_rt_spilled else R.drawable.ic_rt_upright
"imdb" -> R.drawable.ic_rating_imdb
else -> R.drawable.ic_rating_tmdb
}
val muted = rating.source == "rtCritics" && rating.label != "Rotten" && rating.label != "Fresh" && rating.label != "Certified Fresh"
val wordmark = rating.source == "imdb" || rating.source == "tmdb"
Row(horizontalArrangement = Arrangement.spacedBy(markSize * .2857143f), verticalAlignment = Alignment.CenterVertically) {
Image(painterResource(resource), null, Modifier.height(markSize).width(markSize * if (wordmark) 1.8f else 1f),
alpha = if (muted) .55f else 1f, colorFilter = if (muted) desaturated else null)
Text(labels[index], color = C.fg, style = style, maxLines = 1)
}
}
}

View file

@ -28,6 +28,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.Text
@ -65,17 +66,33 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
}
val density = LocalDensity.current
val list = shell.view.listState
LaunchedEffect(state.focusedRow, shell.navFocused) {
val note = state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)
val heights = remember(state.hasHero, state.lanes, note, state.canRequest) {
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 {
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) }
}
}
}
LaunchedEffect(state.focusedRow, shell.navFocused, heights) {
if (!shell.navFocused) {
val row = state.focusedRow
val alreadyFits = state.service != null && row <= 2
if (row <= 1 || alreadyFits) list.animateScrollToItem(0)
else list.animateScrollToItem(row + if (state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) 1 else 0, -with(density) { L.pinnedLine.roundToPx() })
val physical = row + if (row >= state.firstShelf && note) 1 else 0
var top = WolfskinTokens.Shell.zero
for (index in 0 until physical.coerceAtMost(heights.size)) top += heights[index]
val fits = top < D.anchorFitTop && top + heights[physical.coerceAtMost(heights.lastIndex)] <= WolfskinTokens.Shell.stageHeight - D.anchorBottomClearance
val target = if (row <= 1 || fits) 0f else with(density) { (top - L.pinnedLine).toPx() }
var current = WolfskinTokens.Shell.zero
for (index in 0 until list.firstVisibleItemIndex.coerceAtMost(heights.size)) current += heights[index]
animateFrameScroll(list, target - with(density) { current.toPx() } - list.firstVisibleItemScrollOffset)
}
}
Box(modifier.fillMaxSize().testTag("discover-screen")) {
if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { DiscoverBackdrop(it, modifier = Modifier.offset(y = -with(density) { list.firstVisibleItemScrollOffset.toDp() })) }
LazyColumn(state = list, contentPadding = PaddingValues(bottom = WolfskinTokens.Shell.stageHeight - L.pinnedLine), modifier = Modifier.fillMaxSize()) {
if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { DiscoverBackdrop(it, modifier = Modifier.graphicsLayer { translationY = -list.firstVisibleItemScrollOffset.toFloat() }) }
LazyColumn(state = list, contentPadding = PaddingValues(bottom = (WolfskinTokens.Shell.stageHeight - L.pinnedLine - heights.last()).coerceAtLeast(D.frameBottomMin)), modifier = Modifier.fillMaxSize()) {
item(key = "discover-header", contentType = if (state.hasHero && state.service == null) "hero" else "header") {
if (state.hasHero && state.service == null) DiscoverHero(state) else DiscoverHeader(state.shell.person, state.service?.name ?: "Discover", if (state.service != null) "General interest" else "")
}
@ -85,7 +102,13 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
if (state.snapshot.status in setOf(DiscoverStatus.NeedsSignIn, DiscoverStatus.Unavailable)) item(key = "discover-state", contentType = "state") { DiscoverStatePanel(state) }
else {
if (state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) item(key = "discover-note", contentType = "note") {
Text(if (state.snapshot.status == DiscoverStatus.NoSeeds) "Watch something to get picks for ${shell.person.name}." else "Picks unavailable right now.", Modifier.height(L.row_note).padding(start = L.safeSide, top = D.noteTop), color = C.muted, style = WolfskinTokens.Type.body)
Box(Modifier.height(L.row_note).fillMaxWidth()) {
Row(Modifier.offset(L.safeSide, D.noteTop), horizontalArrangement = Arrangement.spacedBy(D.noteGap), verticalAlignment = Alignment.CenterVertically) {
val color = if (state.snapshot.status == DiscoverStatus.NoSeeds) C.muted else C.warn
Glyph(if (state.snapshot.status == DiscoverStatus.NoSeeds) "eye" else "alert", color, Modifier.size(D.noteIcon))
Text(if (state.snapshot.status == DiscoverStatus.NoSeeds) "Watch something to get picks for ${shell.person.name}." else "Picks unavailable right now.", color = color, style = WolfskinTokens.Type.body)
}
}
}
itemsIndexed(state.lanes, key = { _, lane -> lane.id }, contentType = { _, _ -> "shelf" }) { index, lane -> DiscoverShelf(state, lane, index + state.firstShelf) }
}
@ -152,6 +175,7 @@ private fun DiscoverChips(state: DiscoverState) {
@Composable
private fun DiscoverStatePanel(state: DiscoverState) {
val signIn = state.snapshot.status == DiscoverStatus.NeedsSignIn
Box(Modifier.fillMaxWidth().height(L.row_state)) {
Column(Modifier.padding(start = L.safeSide, top = D.stateTop).width(L.stateWidth)
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
.border(WolfskinTokens.Library.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel))
@ -163,6 +187,7 @@ private fun DiscoverStatePanel(state: DiscoverState) {
Text(if (signIn) "Sign this TV in to Conductarr to see picks and request titles. Your library plays as usual." else "Conductarr can’t be reached. Your library still plays.", Modifier.padding(top = L.navBlobAvatarPad, bottom = D.stateTextGap), color = C.muted, style = WolfskinTokens.Type.body)
DiscoverButton("Try again", state.contentFocused) { state.refresh() }
}
}
}
/** Hero and sheet buttons reuse the shell's scale and outside-ring primitives. */

View file

@ -84,7 +84,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
}
} else {
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->
DiscoverPoster(state, item, active && index == column, lane.hidden, Modifier.clickable {
DiscoverPoster(state, item, active && index == column, Modifier.clickable {
state.focus(row, index)
if (lane.hidden) state.overlay = DiscoverOverlayState.Restore(item) else state.openTitle(item)
})
@ -99,7 +99,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
Caption(
item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles",
true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), L.rowHeadingBand + K.posterH + L.captionOffset).width(D.captionWidth),
true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), L.rowHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth),
accentFirstFact = lane.hidden,
accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()),
)
@ -109,7 +109,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
/** Only the focused poster reads the ratings plate; cached observations paint immediately. */
@Composable
fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean, hidden: Boolean, modifier: Modifier = Modifier) {
fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean, modifier: Modifier = Modifier) {
val receipts = state.requests[title.key]
val card = remember(title, receipts, state.shell.person.key) { title.libraryCard(state) }
LibraryPoster(card, focused, focused && state.plateKey == title.key && state.isSignedIn, true,
@ -118,16 +118,8 @@ fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean,
@Composable
internal fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: () -> Unit) {
Column(Modifier.size(K.posterW, K.posterH).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)).border(WolfskinTokens.Focus.ringCardRing / 2, C.border, RoundedCornerShape(WolfskinTokens.Radius.card)).clickable(onClick = onClick).padding(L.buttonGap),
verticalArrangement = Arrangement.spacedBy(if (lane.tail == DiscoverTail.SeeAll) D.seeAllGap else D.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph(when (lane.tail) { DiscoverTail.SeeAll -> "interface"; DiscoverTail.Retry -> "alert"; else -> "down" },
when (lane.tail) { DiscoverTail.Retry -> C.warn; DiscoverTail.SeeAll -> C.accent; else -> C.accentText },
Modifier.size(if (lane.tail == DiscoverTail.SeeAll) L.badge else D.tailIcon))
Text(when (lane.tail) { DiscoverTail.SeeAll -> "See all"; DiscoverTail.Retry -> "Try again"; else -> "Show more" }, color = C.fg, style = if (lane.tail == DiscoverTail.SeeAll) WolfskinTokens.Type.listTitle else WolfskinTokens.Type.button, textAlign = TextAlign.Center)
Text(when (lane.tail) { DiscoverTail.SeeAll -> lane.total.toString(); DiscoverTail.Retry -> "More titles couldn’t load"; else -> "Only titles already shown came back" }, color = C.muted, style = if (lane.tail == DiscoverTail.SeeAll) WolfskinTokens.Type.label else D.tailDescription, textAlign = TextAlign.Center)
}
if (lane.tail == DiscoverTail.SeeAll) LibrarySeeAllCard(lane.total, focused, onClick)
else CatalogueRecoveryCard(lane.tail == DiscoverTail.Retry, focused, onClick)
}
internal fun buildDiscoverCaptionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): String {

View file

@ -46,6 +46,7 @@ class DiscoverState(
val requests = mutableStateMapOf<String, List<DiscoverRequestResult>>()
private val requestSheets = mutableMapOf<Pair<String, String>, RequestSheetState>()
private val hidden = mutableStateMapOf<String, DiscoverTitle>().apply { initial.hidden.forEach { put(it.key, it) } }
private lateinit var collectionCards: LinkedHashMap<String, LibraryTitle>
private var revision by mutableStateOf(0)
private var cachedRevision = -1
private var cachedFilter = ""
@ -114,16 +115,46 @@ class DiscoverState(
fun openTitle(item: DiscoverTitle) {
title = item; titleButton = 0
if (isSignedIn && item.ratings.isNotEmpty()) observedRatings.add(item.key)
onPush("discover-title/${item.key}")
}
fun openCollection(lane: DiscoverLane) {
val titles = lane.fullTitles.associateBy { it.key }
collection = LibraryGridState(lane.title, lane.fullTitles.map { it.libraryCard(this) }, catalogue = true,
onTitle = { openTitle(titles.getValue(it.id)) }, onRecover = ::refresh).apply {
var titles = lane.fullTitles.associateBy { it.key }
collectionCards = LinkedHashMap<String, LibraryTitle>(titles.size).apply {
titles.values.forEach { put(it.key, it.libraryCard(this@DiscoverState)) }
}
lateinit var grid: LibraryGridState
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++
if (collection !== grid) return@launch
val fresh = lanes.firstOrNull { it.id == lane.id }
if (canRequest && fresh != null) {
titles = fresh.fullTitles.associateBy { it.key }
collectionCards = LinkedHashMap<String, LibraryTitle>(titles.size).apply {
titles.values.forEach { put(it.key, it.libraryCard(this@DiscoverState)) }
}
grid.tail = when (fresh.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None }
updateCollectionCards()
} else grid.tail = GridTail.Retry
}
}).apply {
tail = when (lane.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None }
}
collection = grid
onPush("discover-see-all/${lane.id}")
}
private fun updateCollectionCards(changed: DiscoverTitle? = null) {
val grid = collection ?: return
val focused = title?.key ?: grid.focusedTitle?.id
if (changed != null && changed.key in collectionCards) collectionCards[changed.key] = changed.libraryCard(this)
grid.source = collectionCards.values.filter { it.id !in hidden }
val restored = grid.items.indexOfFirst { it.id == focused }
grid.index = if (restored >= 0) restored else grid.index.coerceIn(0,
if (grid.tail == GridTail.None) grid.items.lastIndex.coerceAtLeast(0) else grid.tailIndex)
}
fun isHidden(item: DiscoverTitle): Boolean = item.key in hidden
fun isPick(item: DiscoverTitle): Boolean = snapshot.picks.any { it.key == item.key } || isHidden(item)
@ -154,6 +185,10 @@ class DiscoverState(
return true
}
if (title != null) { titleKey(key); return true }
if (!canRequest) {
if (key == ShellKey.Up) shell.focusNav(shell.currentTab) else if (key == ShellKey.Ok) refresh()
return true
}
when (key) {
ShellKey.Up -> moveRow(-1)
ShellKey.Down -> moveRow(1)
@ -164,11 +199,9 @@ class DiscoverState(
}
ShellKey.Ok -> when {
isCard -> activateCard()
!isSignedIn || snapshot.status == DiscoverStatus.Unavailable -> refresh()
focusedRow == 0 -> hero?.let(::openTitle)
focusedRow == 1 -> { filter = (if (service == null) DISCOVER_FILTERS else listOf("movie", "tv"))[column]; if (service == null) slide = 0; revision++ }
}
else -> Unit
}
return true
}
@ -244,6 +277,13 @@ class DiscoverState(
private fun send(sheet: RequestSheetState, check: Boolean) {
if (!canRequest || (!check && !sheet.canSubmit)) return
if (!check) {
val previous = requestSheets[sheet.title.key to sheet.selectedPerson.key]
if (previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) {
overlay = DiscoverOverlayState.Request(requireNotNull(previous))
return
}
}
val intent = DiscoverRequest(sheet.title, sheet.selectedPerson.key, sheet.viewer.key, isPick(sheet.title))
requestSheets[sheet.title.key to sheet.selectedPerson.key] = sheet
sheet.phase = RequestPhase.Sending
@ -255,6 +295,7 @@ class DiscoverState(
requests[sheet.title.key].orEmpty().forEach { if (it.requester != result.requester) add(it) }
add(result)
}
updateCollectionCards(sheet.title)
}
}
}
@ -262,24 +303,26 @@ class DiscoverState(
fun hide(item: DiscoverTitle) {
if (!canRequest) { refresh(); return }
hidden[item.key] = item; revision++; overlay = DiscoverOverlayState.Hidden(item)
updateCollectionCards()
val profile = shell.person.key
val version = (dismissalVersions[item.key] ?: 0) + 1
dismissalVersions[item.key] = version
scope.launch {
val success = dismissalMutex.withLock { operations.hide(profile, item) }
if (!success && dismissalVersions[item.key] == version) { hidden.remove(item.key); revision++; overlay = null; toast = "Couldn’t hide this pick. Try again." }
if (!success && dismissalVersions[item.key] == version) { hidden.remove(item.key); revision++; updateCollectionCards(); overlay = null; toast = "Couldn’t hide this pick. Try again." }
}
}
fun restore(item: DiscoverTitle) {
if (!canRequest) { refresh(); return }
hidden.remove(item.key); revision++; overlay = null; toast = "Back in your picks."
updateCollectionCards()
val profile = shell.person.key
val version = (dismissalVersions[item.key] ?: 0) + 1
dismissalVersions[item.key] = version
scope.launch {
val success = dismissalMutex.withLock { operations.restore(profile, item) }
if (!success && dismissalVersions[item.key] == version) { hidden[item.key] = item; revision++; toast = "Couldn’t restore this pick. Try again." }
if (!success && dismissalVersions[item.key] == version) { hidden[item.key] = item; revision++; updateCollectionCards(); toast = "Couldn’t restore this pick. Try again." }
}
}

View file

@ -0,0 +1,20 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.foundation.lazy.LazyListState
/** Every frame follows focus with the same bounded scroll tween. */
internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) {
if (distance == 0f) return
list.scroll {
var previous = 0f
animate(0f, distance, animationSpec = tween(WolfskinTokens.Motion.scroll,
easing = CubicBezierEasing(.33f, 1f, .68f, 1f))) { value, _ ->
scrollBy(value - previous)
previous = value
}
}
}

View file

@ -56,6 +56,7 @@ class LibraryGridState(
val onRecover: () -> Unit = {},
val onDiscover: () -> Unit = {},
val onReadAhead: (Int) -> Unit = {},
val observedRatings: MutableSet<String> = mutableSetOf(),
) : ShellContentFocus {
var source by mutableStateOf(items)
var status by mutableStateOf(LibraryStatus.Ready)

View file

@ -65,9 +65,9 @@ fun LibraryPoster(
val color = if (title.badge == "failed") C.fail else C.accent
Box(Modifier.align(Alignment.BottomEnd).padding(WolfskinTokens.Layout.badgeInset)
.size(WolfskinTokens.Layout.badge).background(if (held) C.ok else C.badgeRest, CircleShape)
.border(if (held) WolfskinTokens.Shell.zero else WolfskinTokens.Discover.badgeRing, color, CircleShape), contentAlignment = Alignment.Center) {
.border(if (held) WolfskinTokens.Shell.zero else L.badgeRing, color, CircleShape), contentAlignment = Alignment.Center) {
Glyph(if (held) "check" else if (title.badge == "failed") "alert" else "clock",
if (held) C.onFocus else color, Modifier.size(WolfskinTokens.Discover.badgeIcon))
if (held) C.onFocus else color, Modifier.size(L.badgeIcon))
}
}
}

View file

@ -70,8 +70,9 @@ fun LibraryScreen(
}
LaunchedEffect(focused?.id, active, ratingsSignedIn, ratingsReachable) {
plateKey = null
if (active && ratingsSignedIn && focused != null && (ratingsReachable || focused.id in cachedRatings)) {
if (focused.id !in cachedRatings) delay(WolfskinTokens.Motion.ratingDwell.toLong())
if (active && ratingsSignedIn && focused != null && (ratingsReachable || focused.id in cachedRatings || focused.id in grid.observedRatings)) {
if (focused.id !in cachedRatings && focused.id !in grid.observedRatings) delay(WolfskinTokens.Motion.ratingDwell.toLong())
if (focused.ratings.isNotEmpty()) grid.observedRatings.add(focused.id)
plateKey = focused.id
}
}
@ -223,15 +224,7 @@ private fun LibraryRecovery(grid: LibraryGridState, focused: Boolean) {
private fun LibraryTail(grid: LibraryGridState, focused: Boolean) {
val retry = grid.tail == GridTail.Retry
Box(Modifier.fillMaxWidth().height(K.posterH + K.posterCaptionBand + L.gridRowGap)) {
Column(Modifier.offset(L.safeSide).size(K.posterW, K.posterH).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)).border(G.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.card))
.clickable { grid.index = grid.tailIndex; grid.key(ShellKey.Ok) }.padding(G.tailPadding),
verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon))
Text(if (retry) "Try again" else "Show more", color = C.fg, style = WolfskinTokens.Type.button, textAlign = TextAlign.Center)
Text(if (retry) "More titles couldn’t load" else "Only titles already shown came back", color = C.muted, style = WolfskinTokens.Type.caption, textAlign = TextAlign.Center)
}
CatalogueRecoveryCard(retry, focused, { grid.index = grid.tailIndex; grid.key(ShellKey.Ok) }, Modifier.offset(L.safeSide))
Caption(if (retry) "Try again" else "Show more", if (retry) "${grid.title} stopped loading" else "The last read only repeated titles", focused,
Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(G.captionWidth))
}
@ -261,15 +254,5 @@ private suspend fun animateLibraryScroll(view: RememberedView, headerCount: Int,
if (first < headerCount) WolfskinTokens.Shell.zero else
(if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx()
} + view.listState.firstVisibleItemScrollOffset
val distance = target - current
if (distance == 0f) return
view.listState.scroll {
var previous = 0f
androidx.compose.animation.core.animate(0f, distance,
animationSpec = androidx.compose.animation.core.tween(WolfskinTokens.Motion.scroll,
easing = androidx.compose.animation.core.CubicBezierEasing(.33f, 1f, .68f, 1f))) { value, _ ->
scrollBy(value - previous)
previous = value
}
}
animateFrameScroll(view.listState, target - current)
}

View file

@ -19,7 +19,7 @@ fun LibrarySeeAllCard(count: Int, focused: Boolean, onClick: () -> Unit, modifie
Column(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent)
.background(WolfskinTokens.Color.surface, shape).border(WolfskinTokens.Library.borderWidth, WolfskinTokens.Color.border, shape)
.clickable(onClick = onClick), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Library.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
.clickable(onClick = onClick), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Library.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph("interface", WolfskinTokens.Color.accent, Modifier.size(WolfskinTokens.Layout.badge))
Text("See all", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.listTitle)
Text(count.toString(), color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.label)

View file

@ -321,6 +321,10 @@ object WolfskinTokens {
val tailIcon = 22.0.dp
val tailPadding = 10.0.dp
val tailGap = 5.0.dp
val badgeRing = 1.5.dp
val badgeIcon = 14.0.dp
val seeAllGap = 4.0.dp
val tailDescription = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(500), lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
const val imageReads = 4.0f
const val unknownCriticOpacity = 0.55f
val fallbackPadding = 11.0.dp
@ -366,31 +370,24 @@ object WolfskinTokens {
val statePaddingX = 20.0.dp
val statePaddingY = 17.0.dp
val stateTextGap = 12.0.dp
val captionWidth = 320.0.dp
val plateTop = 7.0.dp
val plateSide = 8.0.dp
val plateFade = 23.0.dp
val noteTop = 7.0.dp
val headerTop = 75.0.dp
val headerGap = 12.0.dp
val captionFactsGap = 9.0.dp
const val captionSeparatorOpacity = 0.4f
val badgeRing = 1.5.dp
val badgeIcon = 14.0.dp
val entryPaddingX = 11.0.dp
val entryPaddingY = 10.0.dp
val entryText = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800), lineHeight = 19.5.sp, letterSpacing = -0.01.em)
val sheetRadioRing = 1.5.dp
val skeletonNameWidth = 130.0.dp
val skeletonNameHeight = 17.0.dp
val tailGap = 5.0.dp
val tailIcon = 22.0.dp
val seeAllGap = 4.0.dp
val tailDescription = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(500), lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
val sheetSubTop = -4.0.dp
val sheetRatingTop = -5.0.dp
val sheetRatingBottom = 3.0.dp
val sheetLabelTop = 4.0.dp
val noteIcon = 15.0.dp
val noteGap = 8.0.dp
val frameBottomMin = 40.0.dp
val anchorFitTop = 350.0.dp
val anchorBottomClearance = 20.0.dp
val ambientInset = 40.0.dp
val ambientBlur = 30.0.dp
const val ambientScale = 1.1f

View file

@ -14,7 +14,8 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult
import org.json.JSONArray
@ -64,23 +65,23 @@ class DiscoverCaptureTest {
rule.waitForIdle()
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
recordTypography(directory, name, state)
recordTypography(directory, name)
}
private fun recordTypography(directory: File, name: String, state: DiscoverState) {
private fun recordTypography(directory: File, name: String) {
val rows = JSONArray()
val labels = listOfNotNull(state.hero?.title, "Request", "For", "Justin (you)", "Hidden picks", "Netflix", "Popular movies")
labels.forEach { text ->
rule.onAllNodesWithText(text, useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
val layouts = mutableListOf<TextLayoutResult>()
node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts)
layouts.forEach { layout ->
rows.put(JSONObject().put("text", text).put("lines", layout.lineCount)
.put("fontSizeSp", layout.layoutInput.style.fontSize.value).put("fontWeight", layout.layoutInput.style.fontWeight?.weight)
.put("lineHeightSp", layout.layoutInput.style.lineHeight.value).put("trim", layout.layoutInput.style.lineHeightStyle?.trim.toString())
.put("density", layout.layoutInput.density.density).put("fontScale", layout.layoutInput.density.fontScale)
.put("left", node.boundsInRoot.left).put("top", node.boundsInRoot.top).put("width", node.boundsInRoot.width).put("height", node.boundsInRoot.height))
}
rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsProperties.Text), useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
val layouts = mutableListOf<TextLayoutResult>()
if (SemanticsActions.GetTextLayoutResult in node.config) node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts)
layouts.forEach { layout ->
val text = layout.layoutInput.text.text
val lines = JSONArray()
repeat(layout.lineCount) { index -> lines.put(text.substring(layout.getLineStart(index), layout.getLineEnd(index, visibleEnd = true))) }
rows.put(JSONObject().put("text", text).put("lines", layout.lineCount).put("lineTexts", lines)
.put("fontSizeSp", layout.layoutInput.style.fontSize.value).put("fontWeight", layout.layoutInput.style.fontWeight?.weight)
.put("lineHeightSp", layout.layoutInput.style.lineHeight.value.takeIf { it.isFinite() } ?: JSONObject.NULL).put("trim", layout.layoutInput.style.lineHeightStyle?.trim.toString())
.put("density", layout.layoutInput.density.density).put("fontScale", layout.layoutInput.density.fontScale)
.put("left", node.boundsInRoot.left).put("top", node.boundsInRoot.top).put("width", node.boundsInRoot.width).put("height", node.boundsInRoot.height))
}
}
File(directory, "$name-type-metrics.json").writeText(rows.toString(2))

View file

@ -117,6 +117,50 @@ class DiscoverKeysTest {
assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase)
}
@Test fun closingAnUncertainReceiptNeverTurnsReopeningIntoAnotherPost() {
operations.next = RequestPhase.NotConfirmed
replay("Request a pick for yourself")
press(Key.Back, Key.DirectionCenter)
assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase)
operations.next = RequestPhase.Receipt
press(Key.DirectionCenter)
assertEquals(1, operations.sent.size)
assertEquals(1, operations.checked)
assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase)
}
@Test fun requestingForAnotherPersonKeepsYourOwnRequestedAction() {
replay("Request a pick for yourself")
press(Key.DirectionCenter)
press(Key.DirectionRight, Key.DirectionCenter)
press(Key.DirectionCenter, Key.DirectionCenter)
assertEquals(setOf("justin", "mindel"), state.requests.getValue(state.title!!.key).map { it.requester }.toSet())
press(Key.DirectionCenter)
press(Key.DirectionLeft, Key.DirectionCenter)
assertTrue(shell.requestsVisible)
assertEquals(2, operations.sent.size)
}
@Test fun choosingAnUncertainRecipientAgainNeverResubmits() {
operations.next = RequestPhase.NotConfirmed
replay("Open Discover")
press(Key.DirectionCenter)
press(Key.DirectionRight, Key.DirectionCenter)
press(Key.DirectionDown, Key.DirectionCenter)
press(Key.DirectionCenter)
assertEquals("shared", operations.sent.single().requester)
press(Key.Back, Key.DirectionCenter)
press(Key.DirectionDown, Key.DirectionCenter)
press(Key.DirectionCenter)
assertEquals(1, operations.sent.size)
assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase)
operations.next = RequestPhase.Receipt
press(Key.DirectionCenter)
assertEquals(1, operations.sent.size)
assertEquals(1, operations.checked)
assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase)
}
@Test fun unknownSeriesSeasonsNeverSubmitAndBackRestoresOpener() {
replay("Open Discover")
rule.runOnIdle { state.openTitle(snapshot.picks.first { it.type == "tv" }); state.openRequest(false) }
@ -155,6 +199,37 @@ class DiscoverKeysTest {
assertEquals(GridTail.ShowMore, state.collection!!.tail)
}
@Test fun collectionRetryReadsNewTitlesAndOpensTheFirstAppendedTitle() {
shell.pickerVisible = false
val lane = state.lanes.first { it.id == "trending" }
state.openCollection(lane.copy(titles = lane.fullTitles.take(6), fullTitles = lane.fullTitles.take(6), tail = DiscoverTail.Retry))
val grid = state.collection!!
grid.index = grid.tailIndex
launch()
press(Key.DirectionCenter)
assertEquals(GridTail.None, grid.tail)
assertEquals(lane.fullTitles.map { it.key }, grid.items.map { it.id })
assertEquals(lane.fullTitles[6].key, grid.focusedTitle?.id)
press(Key.DirectionCenter)
assertEquals(lane.fullTitles[6], state.title)
}
@Test fun collectionDismissalAndUndoKeepTheOriginalOrderAndFocus() {
shell.pickerVisible = false
state.openCollection(state.lanes.first { it.id == "pk-movie" })
val grid = state.collection!!
val original = grid.items.map { it.id }
launch()
press(Key.DirectionCenter)
val opener = state.title!!
press(Key.DirectionRight, Key.DirectionRight)
press(Key.DirectionCenter)
assertFalse(grid.items.any { it.id == opener.key })
press(Key.DirectionCenter)
assertEquals(original, grid.items.map { it.id })
assertEquals(opener.key, grid.focusedTitle?.id)
}
@Test fun collectionBackRestoresTheExactOrigin() {
shell.pickerVisible = false; shell.enterDestination("discover")
state.focus(3, 2)

View file

@ -0,0 +1,33 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import org.junit.Assert.assertEquals
import org.junit.Test
/** Feed projection must not lose titles when a contribution row is too short to display. */
class DiscoverLaneProjectionTest {
@Test fun discardedSeedGroupRemainsAvailableInCatalogue() {
val titles = (1L..6L).map { DiscoverTitle(it, "movie", "Film $it") }
val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(DiscoverSeed("Seed", titles.subList(3, 5))),
lanes = listOf(DiscoverLane("trending", "Trending", titles)), status = DiscoverStatus.Ready)
val rows = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap())
assertEquals(listOf("pk-movie", "trending"), rows.map { it.id })
assertEquals(titles.take(3), rows.first().titles)
assertEquals(titles.drop(3), rows.last().titles)
}
@Test fun catalogueDeduplicationKeepsMediaTypesDistinctAndHiddenPicksLast() {
val movie = DiscoverTitle(1, "movie", "Film")
val series = DiscoverTitle(1, "tv", "Series")
val hidden = DiscoverTitle(2, "movie", "Hidden")
val later = DiscoverTitle(3, "movie", "Later")
val snapshot = DiscoverSnapshot(hidden = listOf(hidden), status = DiscoverStatus.Ready, lanes = listOf(
DiscoverLane("first", "First", listOf(movie, series, hidden)),
DiscoverLane("second", "Second", listOf(movie, series, later))))
val rows = projectDiscoverLanes(snapshot, emptyList(), "all", mapOf(hidden.key to hidden))
assertEquals(listOf(listOf(movie, series), listOf(later), listOf(hidden)), rows.map { it.titles })
assertEquals("hidden", rows.last().id)
val filtered = projectDiscoverLanes(snapshot, emptyList(), "tv", mapOf(hidden.key to hidden))
assertEquals(listOf(listOf(series)), filtered.map { it.titles })
}
}

View file

@ -202,6 +202,10 @@
--library-tailIcon: 44.0px;
--library-tailPadding: 20.0px;
--library-tailGap: 10.0px;
--library-badgeRing: 3.0px;
--library-badgeIcon: 28.0px;
--library-seeAllGap: 8.0px;
--library-tailDescription: 24.0px;
--library-fallbackPadding: 22.0px;
--library-fallbackIcon: 44.0px;
--discover-sheetPaddingX: 64.0px;
@ -237,30 +241,24 @@
--discover-statePaddingX: 40.0px;
--discover-statePaddingY: 34.0px;
--discover-stateTextGap: 24.0px;
--discover-captionWidth: 640.0px;
--discover-plateTop: 14.0px;
--discover-plateSide: 16.0px;
--discover-plateFade: 46.0px;
--discover-noteTop: 14.0px;
--discover-headerTop: 150.0px;
--discover-headerGap: 24.0px;
--discover-captionFactsGap: 18.0px;
--discover-badgeRing: 3.0px;
--discover-badgeIcon: 28.0px;
--discover-entryPaddingX: 22.0px;
--discover-entryPaddingY: 20.0px;
--discover-entryText: 30.0px;
--discover-sheetRadioRing: 3.0px;
--discover-skeletonNameWidth: 260.0px;
--discover-skeletonNameHeight: 34.0px;
--discover-tailGap: 10.0px;
--discover-tailIcon: 44.0px;
--discover-seeAllGap: 8.0px;
--discover-tailDescription: 24.0px;
--discover-sheetSubTop: -8.0px;
--discover-sheetRatingTop: -10.0px;
--discover-sheetRatingBottom: 6.0px;
--discover-sheetLabelTop: 8.0px;
--discover-noteIcon: 30.0px;
--discover-noteGap: 16.0px;
--discover-frameBottomMin: 80.0px;
--discover-anchorFitTop: 700.0px;
--discover-anchorBottomClearance: 40.0px;
--discover-ambientInset: 80.0px;
--discover-ambientBlur: 60.0px;
}

View file

@ -1,6 +1,6 @@
ab28d8fa83a954e827056c4437473529b279ad702cbf9c7faae737bdd25db63d discover-tokens.json
ffc070ec958ece59f2e8eecd1144f43cdaf7fdb53fe51b178f1b17457ea4151a discover-tokens.json
fcff646d59fb39e859c34c05037e05a7fb7583de2fe07315ec0176cf4d597980 generate_tokens.py
1052c79be79e0fb85e64ba5b48a77e7e9dddd114c656e5572f39c0a85e09b9f5 shell-tokens.json
914f60ca5bcbc71459cfde3abc37497b46faab3726fc3816ab1227a555dcc647 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
9e671a8103a1df343d5b39bcd82e9fc28078ac940a04efe5f687ec1a297b92a6 tokens.css
17c45415eb8e334a31a8b187ad2e2c960c35b58e565b19fbbcc9cb5e10717a3a shell-tokens.json
9359aaa6f81650ccfa46c3f0a76d128ea229ff16d79573b342804cc4cb12330e src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
b230411658c4b82e7f9e1677842c60bbf61939567958075fff277bc0561e00ec tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json