From b1b15b384b3c95a37d5871eca071f80b7a41c09a Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 5 Nov 2026 18:32:09 +0100 Subject: [PATCH] Keep catalogue outcomes and recovery consistent across views --- .../tv/ConductarrDiscoverOperations.kt | 2 +- docker/README.md | 16 +++- wolfskin-design/discover-tokens.json | 81 ++----------------- wolfskin-design/shell-tokens.json | 4 + .../wolfskin/design/CatalogueRecoveryCard.kt | 34 ++++++++ .../wolfskin/design/DiscoverOverlay.kt | 2 +- .../wolfskin/design/DiscoverRatings.kt | 61 +++++--------- .../wolfskin/design/DiscoverScreen.kt | 39 +++++++-- .../wolfskin/design/DiscoverShelf.kt | 18 ++--- .../wolfskin/design/DiscoverState.kt | 57 +++++++++++-- .../wolfskin/design/FrameScroll.kt | 20 +++++ .../wolfskin/design/LibraryGridState.kt | 1 + .../wolfskin/design/LibraryPoster.kt | 4 +- .../wolfskin/design/LibraryScreen.kt | 27 ++----- .../wolfskin/design/LibrarySeeAllCard.kt | 2 +- .../wolfskin/design/WolfskinTokens.kt | 21 +++-- .../wolfskin/design/DiscoverCaptureTest.kt | 31 +++---- .../wolfskin/design/DiscoverKeysTest.kt | 75 +++++++++++++++++ .../design/DiscoverLaneProjectionTest.kt | 33 ++++++++ wolfskin-design/tokens.css | 20 +++-- wolfskin-design/tokens.sha256 | 8 +- 21 files changed, 340 insertions(+), 216 deletions(-) create mode 100644 wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt create mode 100644 wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverLaneProjectionTest.kt diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt index a98814ec..0c8a7067 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt @@ -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) } diff --git a/docker/README.md b/docker/README.md index e7e1c072..fb450b83 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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 diff --git a/wolfskin-design/discover-tokens.json b/wolfskin-design/discover-tokens.json index f5aeee07..9eeceebf 100644 --- a/wolfskin-design/discover-tokens.json +++ b/wolfskin-design/discover-tokens.json @@ -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"}, diff --git a/wolfskin-design/shell-tokens.json b/wolfskin-design/shell-tokens.json index 3c605746..70bda98a 100644 --- a/wolfskin-design/shell-tokens.json +++ b/wolfskin-design/shell-tokens.json @@ -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"}, diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt new file mode 100644 index 00000000..5568e985 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt @@ -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) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt index 995875e8..c2b5d2e9 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt @@ -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) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverRatings.kt index e244f2e7..19f2687c 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverRatings.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverRatings.kt @@ -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, modifier: Modifier = Modifier, plate: Boolean = false) { +fun DiscoverRatings(ratings: List, 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) } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt index cef6c68a..6b2f41cf 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt @@ -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. */ diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt index 9f755eb4..e0439d01 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt @@ -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 { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt index 48f9dc78..23b11148 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt @@ -46,6 +46,7 @@ class DiscoverState( val requests = mutableStateMapOf>() private val requestSheets = mutableMapOf, RequestSheetState>() private val hidden = mutableStateMapOf().apply { initial.hidden.forEach { put(it.key, it) } } + private lateinit var collectionCards: LinkedHashMap 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(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(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." } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt new file mode 100644 index 00000000..18b83e5f --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt @@ -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 + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt index 0921f582..f2ab0d07 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt @@ -56,6 +56,7 @@ class LibraryGridState( val onRecover: () -> Unit = {}, val onDiscover: () -> Unit = {}, val onReadAhead: (Int) -> Unit = {}, + val observedRatings: MutableSet = mutableSetOf(), ) : ShellContentFocus { var source by mutableStateOf(items) var status by mutableStateOf(LibraryStatus.Ready) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt index 0684e0a2..4cd7b704 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt @@ -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)) } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt index 3ec1e8d3..f8157eb6 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt @@ -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) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibrarySeeAllCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibrarySeeAllCard.kt index 4f4e6a83..73bba003 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibrarySeeAllCard.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibrarySeeAllCard.kt @@ -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) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index 53a4a548..73ce70a6 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -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 diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt index ee59e51b..c028387c 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt @@ -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() - 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() + 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)) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt index 0876aa4b..8d95e376 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt @@ -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) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverLaneProjectionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverLaneProjectionTest.kt new file mode 100644 index 00000000..ed62d6f4 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverLaneProjectionTest.kt @@ -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 }) + } +} diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index ebeb1901..72925b9c 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -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; } diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index 822a34ee..9afb3e1d 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -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