Keep catalogue outcomes and recovery consistent across views
This commit is contained in:
parent
c1559e2513
commit
b1b15b384b
21 changed files with 340 additions and 216 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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." }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue