Preserve request outcomes and align Discover shelf windows

This commit is contained in:
justinzeus 2026-11-05 17:37:39 +01:00
parent 90e956b157
commit 03ff22ea86
15 changed files with 226 additions and 92 deletions

View file

@ -28,8 +28,10 @@ class ConductarrDiscoverOperations(
require(title.type != "tv" || !title.seasons.isNullOrEmpty()) { "Whole-series season metadata is required" } require(title.type != "tv" || !title.seasons.isNullOrEmpty()) { "Whole-series season metadata is required" }
val body = RequestIntentBody( val body = RequestIntentBody(
mediaType = if (title.type == "movie") RequestIntentBody.MediaType.MOVIE else RequestIntentBody.MediaType.TV, mediaType = if (title.type == "movie") RequestIntentBody.MediaType.MOVIE else RequestIntentBody.MediaType.TV,
recommendationProfile = intent.profile, requester = intent.requester, recommendationProfile = intent.profile.takeIf { intent.fromRecommendation }, requester = intent.requester,
seasons = title.seasons?.map(Int::toLong), source = RequestIntentBody.Source.RECOMMENDATION, tmdbId = title.tmdb, seasons = title.seasons?.map(Int::toLong),
source = if (intent.fromRecommendation) RequestIntentBody.Source.RECOMMENDATION else RequestIntentBody.Source.TITLE,
tmdbId = title.tmdb,
) )
return when (val result = client.mutate(TvRoutes.request(body))) { return when (val result = client.mutate(TvRoutes.request(body))) {
is MutationResult.Success -> result.value.outcome() is MutationResult.Success -> result.value.outcome()

View file

@ -304,6 +304,35 @@
"px": 34, "px": 34,
"dp": 17, "dp": 17,
"source": "locked index.html:1851" "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"
} }
} }
} }

View file

@ -76,8 +76,8 @@ fun DiscoverCollection(state: DiscoverState, collection: DiscoverCollectionState
val item = collection.items.getOrNull(collection.index) val item = collection.items.getOrNull(collection.index)
Caption( Caption(
item?.title ?: if (collection.lane.tail == DiscoverTail.Retry) "Try again" else "Show more", item?.title ?: if (collection.lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
if (item != null) captionFacts(item, state, false) else listOf(CaptionFact("The last read only repeated titles")), if (item != null) buildDiscoverCaptionFacts(item, state, false) else listOf(CaptionFact("The last read only repeated titles")),
true, Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(D.captionWidth), true, Modifier.offset(L.safeSide + K.posterPitch * collection.column, K.posterH + L.captionOffset).width(D.captionWidth),
) )
} }
} }

View file

@ -35,7 +35,7 @@ class DiscoverFixture(json: String, private val artPath: (String) -> String = {
val items = pool.filter { genre in normalizedGenres(it.genres) && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.type == "movie") } val items = pool.filter { genre in normalizedGenres(it.genres) && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.type == "movie") }
DiscoverLane("genre-$genre", genre, items.take(20), items.size, fullTitles = items) DiscoverLane("genre-$genre", genre, items.take(20), items.size, fullTitles = items)
} }
return DiscoverSnapshot(picks, groups.values.map { DiscoverSeed(it.first, it.second.toList()) }, services, lanes, status = DiscoverStatus.Ready) return DiscoverSnapshot(picks, groups.values.map { DiscoverSeed(it.first, it.second.toList()) }, services, lanes, status = DiscoverStatus.Ready, reusesFixtureLanes = true)
} }
fun service(service: DiscoverService, type: String): List<DiscoverLane> { fun service(service: DiscoverService, type: String): List<DiscoverLane> {

View file

@ -0,0 +1,41 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import kotlin.math.ceil
/** Dropped seed groups must not suppress titles from the later catalogue lanes. */
internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List<DiscoverTitle>, filter: String, hidden: Map<String, DiscoverTitle>): List<DiscoverLane> {
fun matches(item: DiscoverTitle) = filter == "all" || item.type == filter
val seen = heroes.mapTo(mutableSetOf()) { it.key }
val result = mutableListOf<DiscoverLane>()
fun take(items: List<DiscoverTitle>, limit: Int) = items.filter { matches(it) && it.key !in hidden && it.key !in seen }.take(limit).filter { seen.add(it.key) }
if (snapshot.status == DiscoverStatus.Ready) {
val groups = snapshot.seeds.map { seed -> seed.copy(items = seed.items.filter { matches(it) && it.key !in hidden }) }.filter { it.items.isNotEmpty() }.take(3)
for ((type, name) in listOf("movie" to "Movies", "tv" to "Series")) {
val all = snapshot.picks.filter { it.type == type && matches(it) && it.key !in hidden }
val count = all.count { it.key !in seen }
val items = take(all, minOf(12, ceil(count.toDouble() / (groups.size + 1)).toInt()))
if (items.isNotEmpty()) result += DiscoverLane("pk-$type", "$name for you", items, all.size, pick = true, fullTitles = all)
}
val size = if (groups.isEmpty()) 0 else minOf(8, ceil(snapshot.picks.count { matches(it) && it.key !in seen && it.key !in hidden }.toDouble() / groups.size).toInt())
groups.forEachIndexed { index, seed ->
val items = take(seed.items, size)
if (items.size >= 3) result += DiscoverLane("pk-w$index", "Because you watched ${seed.title}", items, seed.items.size, pick = true, fullTitles = seed.items)
}
}
val displayed = heroes.mapTo(mutableSetOf()) { it.key }
result.forEach { lane -> lane.titles.forEach { displayed.add(it.key) } }
if (snapshot.services.isNotEmpty()) result += DiscoverLane("svc", "Services", emptyList())
snapshot.lanes.forEach { lane ->
val source = lane.fullTitles.filter { matches(it) && it.key !in displayed && it.key !in hidden }
val items = source.take(if (lane.id.startsWith("new-")) 12 else WolfskinTokens.Behaviour.rowLimit.toInt())
if (items.isNotEmpty() || lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore) {
result += lane.copy(titles = items, fullTitles = source, total = if (lane.id.startsWith("new-")) items.size else source.size,
tail = if (lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore) lane.tail else if (!lane.id.startsWith("new-") && source.size > items.size) DiscoverTail.SeeAll else DiscoverTail.None)
if (!snapshot.reusesFixtureLanes) items.forEach { displayed.add(it.key) }
}
}
val hiddenItems = (snapshot.hidden + snapshot.picks).distinctBy { it.key }.filter { it.key in hidden && matches(it) }
if (hiddenItems.isNotEmpty()) result += DiscoverLane("hidden", "Hidden picks", hiddenItems, hidden = true)
return result
}

View file

@ -44,6 +44,7 @@ data class DiscoverSnapshot(
val hidden: List<DiscoverTitle> = emptyList(), val hidden: List<DiscoverTitle> = emptyList(),
val status: DiscoverStatus = DiscoverStatus.NeedsSignIn, val status: DiscoverStatus = DiscoverStatus.NeedsSignIn,
val savedAt: String? = null, val savedAt: String? = null,
val reusesFixtureLanes: Boolean = false,
) )
/** Lane identity survives lazy recycling and provider refreshes. */ /** Lane identity survives lazy recycling and provider refreshes. */
@ -59,7 +60,7 @@ data class DiscoverLane(
) )
/** Caller identity is deliberately absent: the authenticated client supplies the actor. */ /** Caller identity is deliberately absent: the authenticated client supplies the actor. */
data class DiscoverRequest(val title: DiscoverTitle, val requester: String, val profile: String) data class DiscoverRequest(val title: DiscoverTitle, val requester: String, val profile: String, val fromRecommendation: Boolean = true)
/** Request outcomes distinguish uncertainty from an acknowledged receipt. */ /** Request outcomes distinguish uncertainty from an acknowledged receipt. */
data class DiscoverRequestResult( data class DiscoverRequestResult(

View file

@ -2,6 +2,8 @@
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -33,11 +35,10 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.tv.material3.Text import androidx.tv.material3.Text
import kotlinx.coroutines.delay
import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
@ -49,12 +50,30 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
val services = lane.id == "svc" val services = lane.id == "svc"
val height = if (services) K.entryH else K.posterH val height = if (services) K.entryH else K.posterH
val cards = rememberLazyListState() val cards = rememberLazyListState()
val column = state.shell.view.cards[lane.id] ?: 0 val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1
val column = (state.shell.view.cards[lane.id] ?: 0).coerceIn(0, (count - 1).coerceAtLeast(0))
val active = state.contentFocused && state.focusedRow == row val active = state.contentFocused && state.focusedRow == row
LaunchedEffect(column) { cards.animateScrollToItem((column - 5).coerceAtLeast(0)) } val slots = ((L.contentWidth + K.posterGap) / K.posterPitch).toInt()
val first = state.shell.view.cards["first:${lane.id}"] ?: 0
val density = LocalDensity.current
var initialized by remember { mutableStateOf(false) }
LaunchedEffect(column, count) {
var target = first
if (column > target + slots - 2) target = column - (slots - 2)
if (column < target + 1) target = (column - 1).coerceAtLeast(0)
target = target.coerceIn(0, (count - slots).coerceAtLeast(0))
state.shell.view.cards["first:${lane.id}"] = target
val offset = cards.layoutInfo.visibleItemsInfo.firstOrNull { it.index == target }?.offset
if (initialized && offset != null) {
if (offset != 0) cards.animateScrollBy(offset.toFloat(), tween(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
} else cards.scrollToItem(target)
initialized = true
}
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) { Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) {
Text(lane.title, Modifier.offset(L.safeSide), color = C.fg, style = WolfskinTokens.Type.rowHeading, maxLines = 1) Text(lane.title, Modifier.offset(L.safeSide), color = C.fg, style = WolfskinTokens.Type.rowHeading, maxLines = 1)
LazyRow(state = cards, contentPadding = PaddingValues(horizontal = L.safeSide, vertical = K.posterGap), horizontalArrangement = Arrangement.spacedBy(K.posterGap), LazyRow(state = cards, userScrollEnabled = false,
contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap),
horizontalArrangement = Arrangement.spacedBy(K.posterGap),
modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) { modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) {
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service -> if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard) Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
@ -80,8 +99,8 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
val item = lane.titles.getOrNull(column) val item = lane.titles.getOrNull(column)
Caption( Caption(
item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more", 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) captionFacts(item, state, lane.hidden) else listOf(CaptionFact(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")), if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else listOf(CaptionFact(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, 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(D.captionWidth),
) )
} }
} }
@ -102,12 +121,14 @@ fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean,
DiscoverRatings(title.ratings, Modifier.fillMaxWidth(), plate = true) DiscoverRatings(title.ratings, Modifier.fillMaxWidth(), plate = true)
} }
} }
val result = state.requests[title.key] val receipts = state.requests[title.key].orEmpty()
if (state.shell.person.key in title.heldBy || result != null) { val held = state.shell.person.key in title.heldBy
val failed = receipts.isNotEmpty() && receipts.all { it.stage == "Failed" }
if (held || receipts.isNotEmpty()) {
Box(Modifier.align(Alignment.BottomEnd).offset(-L.badgeInset, -L.badgeInset).size(L.badge) Box(Modifier.align(Alignment.BottomEnd).offset(-L.badgeInset, -L.badgeInset).size(L.badge)
.background(if (result == null) C.ok else C.badgeRest, CircleShape) .background(if (held) C.ok else C.badgeRest, CircleShape)
.border(if (result == null) WolfskinTokens.Shell.zero else D.badgeRing, if (result?.stage == "Failed") C.fail else C.accent, CircleShape), contentAlignment = Alignment.Center) { .border(if (held) WolfskinTokens.Shell.zero else D.badgeRing, if (failed) C.fail else C.accent, CircleShape), contentAlignment = Alignment.Center) {
Glyph(if (result == null) "check" else if (result.stage == "Failed") "alert" else "clock", if (result == null) C.onFocus else if (result.stage == "Failed") C.fail else C.accent, Modifier.size(D.badgeIcon)) Glyph(if (held) "check" else if (failed) "alert" else "clock", if (held) C.onFocus else if (failed) C.fail else C.accent, Modifier.size(D.badgeIcon))
} }
} }
} }
@ -119,21 +140,26 @@ internal fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: ()
Column(Modifier.size(K.posterW, K.posterH).focusScale(focused, WolfskinTokens.Focus.scaleCard) 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) .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), .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(L.buttonGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) { verticalArrangement = Arrangement.spacedBy(if (lane.tail == DiscoverTail.SeeAll) D.seeAllGap else D.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph(if (lane.tail == DiscoverTail.SeeAll) "interface" else "alert", if (lane.tail == DiscoverTail.Retry) C.warn else C.accent, Modifier.size(L.badge)) Glyph(when (lane.tail) { DiscoverTail.SeeAll -> "interface"; DiscoverTail.Retry -> "alert"; else -> "down" },
Text(when (lane.tail) { DiscoverTail.SeeAll -> "See all"; DiscoverTail.Retry -> "Try again"; else -> "Show more" }, color = C.fg, style = WolfskinTokens.Type.listTitle) when (lane.tail) { DiscoverTail.Retry -> C.warn; DiscoverTail.SeeAll -> C.accent; else -> C.accentText },
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 = WolfskinTokens.Type.caption) 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)
} }
} }
internal fun captionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): List<CaptionFact> = buildList { internal fun buildDiscoverCaptionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): List<CaptionFact> = buildList {
if (hidden) add(CaptionFact("Hidden from your picks", true)) if (hidden) add(CaptionFact("Hidden from your picks", true))
title.year?.let { add(CaptionFact(it.toString())) } title.year?.let { add(CaptionFact(it.toString())) }
add(CaptionFact(if (title.type == "tv") title.seasonCount?.let { "$it season${if (it == 1) "" else "s"}" } ?: "Series" else title.runtimeSeconds?.let { "${it / 3600}h ${(it / 60) % 60}m" } ?: "Movie")) add(CaptionFact(if (title.type == "tv") title.seasonCount?.let { "$it season${if (it == 1) "" else "s"}" } ?: "Series" else title.runtimeSeconds?.let { "${it / 3600}h ${(it / 60) % 60}m" } ?: "Movie"))
val receipts = state.requests[title.key].orEmpty()
if (state.shell.person.key in title.heldBy) add(CaptionFact("In your library", true)) if (state.shell.person.key in title.heldBy) add(CaptionFact("In your library", true))
else if (state.requests.containsKey(title.key)) { else if (receipts.isNotEmpty()) {
val request = state.requests.getValue(title.key) val recipients = receipts.joinToString(", ") { request -> if (request.requester == state.shell.person.key) "you" else state.shell.people.firstOrNull { it.key == request.requester }?.name ?: request.requester.orEmpty() }
val recipient = if (request.requester == state.shell.person.key) "you" else state.shell.people.firstOrNull { it.key == request.requester }?.name ?: request.requester.orEmpty() add(CaptionFact("Requested for $recipients · ${receipts.first().stage}", true))
add(CaptionFact("Requested for $recipient · ${request.stage}", true)) } else if (title.heldBy.isNotEmpty()) {
} else title.heldBy.firstOrNull()?.let { key -> add(CaptionFact("In ${state.shell.people.firstOrNull { it.key == key }?.name ?: key}’s library", true)) } val names = title.heldBy.joinToString(" and ") { key -> state.shell.people.firstOrNull { it.key == key }?.name ?: key }
add(CaptionFact("In $names’s library", true))
}
} }

View file

@ -9,7 +9,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlin.math.ceil
/** One person owns a Discover view; the shell retains its root list and card memory. */ /** One person owns a Discover view; the shell retains its root list and card memory. */
class DiscoverState( class DiscoverState(
@ -23,8 +22,16 @@ class DiscoverState(
) { ) {
var snapshot by mutableStateOf(initial) var snapshot by mutableStateOf(initial)
private set private set
var filter by mutableStateOf("all") private var serviceFilter by mutableStateOf("movie")
var slide by mutableStateOf(0) var filter: String
get() = if (service != null) serviceFilter else DISCOVER_FILTERS[shell.rememberedView("discover").cards["filter"] ?: 0]
set(value) {
if (service != null) serviceFilter = value
else shell.rememberedView("discover").cards["filter"] = DISCOVER_FILTERS.indexOf(value)
}
var slide: Int
get() = (shell.rememberedView("discover").cards["slide"] ?: 0).coerceIn(0, (heroes.size - 1).coerceAtLeast(0))
set(value) { shell.rememberedView("discover").cards["slide"] = value }
var title by mutableStateOf<DiscoverTitle?>(null) var title by mutableStateOf<DiscoverTitle?>(null)
var service by mutableStateOf<DiscoverService?>(null) var service by mutableStateOf<DiscoverService?>(null)
var collection by mutableStateOf<DiscoverCollectionState?>(null) var collection by mutableStateOf<DiscoverCollectionState?>(null)
@ -36,7 +43,8 @@ class DiscoverState(
var toast by mutableStateOf<String?>(null) var toast by mutableStateOf<String?>(null)
val observedRatings = mutableSetOf<String>() val observedRatings = mutableSetOf<String>()
var plateKey by mutableStateOf<String?>(null) var plateKey by mutableStateOf<String?>(null)
val requests = mutableStateMapOf<String, DiscoverRequestResult>() 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 val hidden = mutableStateMapOf<String, DiscoverTitle>().apply { initial.hidden.forEach { put(it.key, it) } }
private var armedCard: String? = null private var armedCard: String? = null
private var revision by mutableStateOf(0) private var revision by mutableStateOf(0)
@ -48,11 +56,15 @@ class DiscoverState(
private var cachedHeroesFilter = "" private var cachedHeroesFilter = ""
private var cachedHeroes = emptyList<DiscoverTitle>() private var cachedHeroes = emptyList<DiscoverTitle>()
private val dismissalMutex = Mutex() private val dismissalMutex = Mutex()
private var dismissalVersion = 0 private val dismissalVersions = mutableMapOf<String, Int>()
private var rootFilter = "all" private var rootFilter = "all"
val isSignedIn: Boolean get() = snapshot.status != DiscoverStatus.NeedsSignIn val isSignedIn: Boolean get() = snapshot.status != DiscoverStatus.NeedsSignIn
val hasHero: Boolean get() = heroes.isNotEmpty() && snapshot.status !in setOf(DiscoverStatus.NeedsSignIn, DiscoverStatus.Unavailable, DiscoverStatus.Loading) val canRequest: Boolean get() = when (snapshot.status) {
DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable -> true
else -> false
}
val hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && canRequest
val heroes: List<DiscoverTitle> get() { val heroes: List<DiscoverTitle> get() {
if (cachedHeroesRevision != revision || cachedHeroesFilter != filter) { if (cachedHeroesRevision != revision || cachedHeroesFilter != filter) {
val source = if (snapshot.status == DiscoverStatus.Ready) snapshot.picks else snapshot.lanes.firstOrNull { it.id == "trending" }?.titles.orEmpty() val source = if (snapshot.status == DiscoverStatus.Ready) snapshot.picks else snapshot.lanes.firstOrNull { it.id == "trending" }?.titles.orEmpty()
@ -63,7 +75,7 @@ class DiscoverState(
} }
return cachedHeroes return cachedHeroes
} }
val hero: DiscoverTitle? get() = heroes.getOrNull(slide.coerceAtMost((heroes.size - 1).coerceAtLeast(0))) val hero: DiscoverTitle? get() = heroes.getOrNull(slide)
val lanes: List<DiscoverLane> get() { val lanes: List<DiscoverLane> get() {
if (cachedRevision != revision || cachedFilter != filter || cachedService != service) { if (cachedRevision != revision || cachedFilter != filter || cachedService != service) {
cachedLanes = buildLanes(); cachedRevision = revision; cachedFilter = filter; cachedService = service cachedLanes = buildLanes(); cachedRevision = revision; cachedFilter = filter; cachedService = service
@ -74,14 +86,25 @@ class DiscoverState(
val rowCount: Int get() = firstShelf + lanes.size val rowCount: Int get() = firstShelf + lanes.size
val focusedRow: Int get() = shell.view.row.coerceIn(0, (rowCount - 1).coerceAtLeast(0)) val focusedRow: Int get() = shell.view.row.coerceIn(0, (rowCount - 1).coerceAtLeast(0))
val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf) val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf)
val column: Int get() = shell.view.cards[rowKey].orZero() val column: Int get() = shell.view.cards[rowKey].orZero().coerceAtMost(maxColumn())
val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() } val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() }
val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null
val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null
fun focus(row: Int, column: Int = 0) { fun focus(row: Int, column: Int = 0) {
shell.navFocused = false; shell.view.row = row shell.navFocused = false; shell.view.row = row
shell.view.cards[rowKey] = column shell.view.cards[rowKey] = column.coerceIn(0, maxColumn())
}
private fun moveRow(direction: Int) {
val target = focusedRow + direction
if (target < if (hasHero) 0 else 1) { shell.focusNav(shell.currentTab); return }
if (target >= rowCount) return
val key = when (target) { 0 -> "phero"; 1 -> "dchips"; else -> lanes[target - firstShelf].id }
val nearest = if (focusedRow >= firstShelf && target >= firstShelf)
column - shell.view.cards["first:$rowKey"].orZero() + shell.view.cards["first:$key"].orZero()
else 0
focus(target, shell.view.cards[key] ?: nearest.coerceAtLeast(0))
} }
fun openTitle(item: DiscoverTitle) { fun openTitle(item: DiscoverTitle) {
@ -113,6 +136,7 @@ class DiscoverState(
return true return true
} }
if (shell.route != "discover" && !shell.route.startsWith("discover-")) return false if (shell.route != "discover" && !shell.route.startsWith("discover-")) return false
if (snapshot.status == DiscoverStatus.Loading) { if (down) shell.focusNav(shell.currentTab); return true }
if (title == null) collection?.let { return it.key(key, down) } if (title == null) collection?.let { return it.key(key, down) }
if (isCard && key == ShellKey.Ok) { if (isCard && key == ShellKey.Ok) {
val identity = "$rowKey:$column" val identity = "$rowKey:$column"
@ -132,8 +156,8 @@ class DiscoverState(
} }
if (title != null) { titleKey(key); return true } if (title != null) { titleKey(key); return true }
when (key) { when (key) {
ShellKey.Up -> if (focusedRow <= if (hasHero) 0 else 1) shell.focusNav(shell.currentTab) else focus(focusedRow - 1, shell.view.cards[if (focusedRow == 2) "dchips" else lanes.getOrNull(focusedRow - 3)?.id].orZero()) ShellKey.Up -> moveRow(-1)
ShellKey.Down -> focus((focusedRow + 1).coerceAtMost(rowCount - 1)) ShellKey.Down -> moveRow(1)
ShellKey.Left, ShellKey.Right -> { ShellKey.Left, ShellKey.Right -> {
val direction = if (key == ShellKey.Left) -1 else 1 val direction = if (key == ShellKey.Left) -1 else 1
if (focusedRow == 0) slide = (slide + direction).coerceIn(0, (heroes.size - 1).coerceAtLeast(0)) if (focusedRow == 0) slide = (slide + direction).coerceIn(0, (heroes.size - 1).coerceAtLeast(0))
@ -142,7 +166,7 @@ class DiscoverState(
ShellKey.Ok -> when { ShellKey.Ok -> when {
!isSignedIn || snapshot.status == DiscoverStatus.Unavailable -> refresh() !isSignedIn || snapshot.status == DiscoverStatus.Unavailable -> refresh()
focusedRow == 0 -> hero?.let(::openTitle) focusedRow == 0 -> hero?.let(::openTitle)
focusedRow == 1 -> { filter = (if (service == null) listOf("all", "movie", "tv") else listOf("movie", "tv"))[column]; slide = 0; revision++ } focusedRow == 1 -> { filter = (if (service == null) DISCOVER_FILTERS else listOf("movie", "tv"))[column]; if (service == null) slide = 0; revision++ }
} }
else -> Unit else -> Unit
} }
@ -167,12 +191,16 @@ class DiscoverState(
} }
private fun titleKey(key: ShellKey) { private fun titleKey(key: ShellKey) {
if (!canRequest) {
if (key == ShellKey.Up) shell.focusNav(shell.currentTab) else if (key == ShellKey.Ok) refresh()
return
}
when (key) { when (key) {
ShellKey.Up -> shell.focusNav(shell.currentTab) ShellKey.Up -> shell.focusNav(shell.currentTab)
ShellKey.Left -> titleButton = (titleButton - 1).coerceAtLeast(0) ShellKey.Left -> titleButton = (titleButton - 1).coerceAtLeast(0)
ShellKey.Right -> titleButton = (titleButton + 1).coerceAtMost(if (title?.let(::isPick) == true) 2 else 1) ShellKey.Right -> titleButton = (titleButton + 1).coerceAtMost(if (title?.let(::isPick) == true) 2 else 1)
ShellKey.Ok -> when (titleButton) { ShellKey.Ok -> when (titleButton) {
0 -> if (requests[requireNotNull(title).key]?.requester == shell.person.key) shell.requestsVisible = true else openRequest(false) 0 -> if (requests[requireNotNull(title).key]?.any { it.requester == shell.person.key } == true) shell.requestsVisible = true else openRequest(false)
1 -> openRequest(true) 1 -> openRequest(true)
2 -> title?.let { if (it.key in hidden) restore(it) else hide(it) } 2 -> title?.let { if (it.key in hidden) restore(it) else hide(it) }
} }
@ -181,8 +209,12 @@ class DiscoverState(
} }
fun openRequest(other: Boolean, item: DiscoverTitle = requireNotNull(title), phase: RequestPhase = RequestPhase.Choose) { fun openRequest(other: Boolean, item: DiscoverTitle = requireNotNull(title), phase: RequestPhase = RequestPhase.Choose) {
if (!isSignedIn || snapshot.status == DiscoverStatus.Unavailable) { refresh(); return } if (!canRequest) { refresh(); return }
overlay = DiscoverOverlayState.Request(RequestSheetState(item, shell.person, shell.people, other, phase)) val choice = RequestSheetState(item, shell.person, shell.people, other, phase)
val previous = requestSheets[item.key to choice.selectedPerson.key]
overlay = DiscoverOverlayState.Request(
if (phase == RequestPhase.Choose && previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) requireNotNull(previous) else choice,
)
} }
private fun overlayKey(key: ShellKey) { private fun overlayKey(key: ShellKey) {
@ -211,65 +243,54 @@ class DiscoverState(
} }
private fun send(sheet: RequestSheetState, check: Boolean) { private fun send(sheet: RequestSheetState, check: Boolean) {
if (!isSignedIn || snapshot.status == DiscoverStatus.Unavailable || (!check && !sheet.canSubmit)) return if (!canRequest || (!check && !sheet.canSubmit)) 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 sheet.phase = RequestPhase.Sending
scope.launch { scope.launch {
val intent = DiscoverRequest(sheet.title, sheet.selectedPerson.key, shell.person.key)
val result = (if (check) operations.check(intent) else operations.request(intent)).copy(requester = intent.requester) val result = (if (check) operations.check(intent) else operations.request(intent)).copy(requester = intent.requester)
sheet.result = result; sheet.phase = result.phase; sheet.focus = sheet.buttonIndex sheet.result = result; sheet.phase = result.phase; sheet.focus = sheet.buttonIndex
if (result.phase == RequestPhase.Receipt) requests[sheet.title.key] = result if (result.phase == RequestPhase.Receipt) {
requests[sheet.title.key] = buildList {
requests[sheet.title.key].orEmpty().forEach { if (it.requester != result.requester) add(it) }
add(result)
}
}
} }
} }
fun hide(item: DiscoverTitle) { fun hide(item: DiscoverTitle) {
if (!canRequest) { refresh(); return }
hidden[item.key] = item; revision++; overlay = DiscoverOverlayState.Hidden(item) hidden[item.key] = item; revision++; overlay = DiscoverOverlayState.Hidden(item)
val version = ++dismissalVersion val profile = shell.person.key
val version = (dismissalVersions[item.key] ?: 0) + 1
dismissalVersions[item.key] = version
scope.launch { scope.launch {
val success = dismissalMutex.withLock { operations.hide(shell.person.key, item) } val success = dismissalMutex.withLock { operations.hide(profile, item) }
if (!success && dismissalVersion == 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++; overlay = null; toast = "Couldn’t hide this pick. Try again." }
} }
} }
fun restore(item: DiscoverTitle) { fun restore(item: DiscoverTitle) {
if (!canRequest) { refresh(); return }
hidden.remove(item.key); revision++; overlay = null; toast = "Back in your picks." hidden.remove(item.key); revision++; overlay = null; toast = "Back in your picks."
val version = ++dismissalVersion val profile = shell.person.key
val version = (dismissalVersions[item.key] ?: 0) + 1
dismissalVersions[item.key] = version
scope.launch { scope.launch {
val success = dismissalMutex.withLock { operations.restore(shell.person.key, item) } val success = dismissalMutex.withLock { operations.restore(profile, item) }
if (!success && dismissalVersion == 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++; toast = "Couldn’t restore this pick. Try again." }
} }
} }
fun refresh() { scope.launch { snapshot = operations.refresh(); revision++; toast = if (snapshot.status == DiscoverStatus.Unavailable) "Still can’t reach it. The last saved view stays on screen." else null } } fun refresh() { scope.launch { snapshot = operations.refresh(); revision++; toast = if (snapshot.status == DiscoverStatus.Unavailable) "Still can’t reach it. The last saved view stays on screen." else null } }
private fun matches(item: DiscoverTitle): Boolean = filter == "all" || item.type == filter private fun matches(item: DiscoverTitle): Boolean = filter == "all" || item.type == filter
private fun buildLanes(): List<DiscoverLane> { private fun buildLanes(): List<DiscoverLane> = service?.let {
service?.let { return serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() } } serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() }
val seen = heroes.mapTo(mutableSetOf()) { it.key } } ?: projectDiscoverLanes(snapshot, heroes, filter, hidden)
val result = mutableListOf<DiscoverLane>()
fun take(items: List<DiscoverTitle>, limit: Int) = items.filter { matches(it) && it.key !in hidden && it.key !in seen }.take(limit).filter { seen.add(it.key) }
if (snapshot.status == DiscoverStatus.Ready) {
val groups = snapshot.seeds.take(3)
for ((type, name) in listOf("movie" to "Movies", "tv" to "Series")) {
val all = snapshot.picks.filter { it.type == type && matches(it) && it.key !in hidden }
val count = all.count { it.key !in seen }
val items = take(all, minOf(12, ceil(count.toDouble() / (groups.size + 1)).toInt()))
if (items.isNotEmpty()) result += DiscoverLane("pk-$type", "$name for you", items, all.size, pick = true, fullTitles = all)
}
val size = if (groups.isEmpty()) 0 else minOf(8, ceil(snapshot.picks.count { matches(it) && it.key !in seen && it.key !in hidden }.toDouble() / groups.size).toInt())
groups.forEachIndexed { index, seed -> val items = take(seed.items, size); if (items.size >= 3) result += DiscoverLane("pk-w$index", "Because you watched ${seed.title}", items, seed.items.size, pick = true, fullTitles = seed.items) }
}
if (snapshot.services.isNotEmpty()) result += DiscoverLane("svc", "Services", emptyList())
snapshot.lanes.forEach { lane ->
val source = lane.fullTitles.filter { matches(it) && it.key !in seen && it.key !in hidden }
val items = source.take(if (lane.id.startsWith("new-")) 12 else WolfskinTokens.Behaviour.rowLimit.toInt())
if (items.isNotEmpty() || lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore))
result += lane.copy(titles = items, fullTitles = source, total = if (lane.id.startsWith("new-")) items.size else source.size,
tail = if (lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)) lane.tail else if (!lane.id.startsWith("new-") && source.size > items.size) DiscoverTail.SeeAll else DiscoverTail.None)
}
val hiddenItems = (snapshot.hidden + snapshot.picks).distinctBy { it.key }.filter { it.key in hidden && matches(it) }
if (hiddenItems.isNotEmpty()) result += DiscoverLane("hidden", "Hidden picks", hiddenItems, hidden = true)
return result
}
} }
private fun Int?.orZero(): Int = this ?: 0 private fun Int?.orZero(): Int = this ?: 0
private val DISCOVER_FILTERS = listOf("all", "movie", "tv")

View file

@ -35,12 +35,14 @@ fun DiscoverTitleDestination(state: DiscoverState, modifier: Modifier = Modifier
Text(title.overview, color = C.muted, style = WolfskinTokens.Type.body, maxLines = 3) Text(title.overview, color = C.muted, style = WolfskinTokens.Type.body, maxLines = 3)
Spacer(Modifier.height(D.heroOverviewGap)) Spacer(Modifier.height(D.heroOverviewGap))
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
val receipt = state.requests[title.key]?.takeIf { it.requester == state.shell.person.key } if (!state.canRequest) DiscoverButton("Try again", focused) { state.refresh() } else {
DiscoverButton(if (receipt == null) "Request" else "Requested · ${receipt.stage}", focused && state.titleButton == 0, glyph = if (receipt == null) "plus" else "clock") { val receipt = state.requests[title.key]?.firstOrNull { it.requester == state.shell.person.key }
state.titleButton = 0; state.key(ShellKey.Ok, true) DiscoverButton(if (receipt == null) "Request" else "Requested · ${receipt.stage}", focused && state.titleButton == 0, glyph = if (receipt == null) "plus" else "clock") {
state.titleButton = 0; state.key(ShellKey.Ok, true)
}
DiscoverButton(if (state.shell.person.key in title.heldBy) "Add for someone" else "For someone else", focused && state.titleButton == 1, glyph = "profile", primary = false) { state.openRequest(true) }
if (state.isPick(title)) DiscoverButton(if (state.isHidden(title)) "Show again" else "Not interested", focused && state.titleButton == 2, glyph = if (state.isHidden(title)) "undo" else "hide", primary = false) { state.titleButton = 2; state.key(ShellKey.Ok, true) }
} }
DiscoverButton(if (state.shell.person.key in title.heldBy) "Add for someone" else "For someone else", focused && state.titleButton == 1, glyph = "profile", primary = false) { state.openRequest(true) }
if (state.isPick(title)) DiscoverButton(if (state.isHidden(title)) "Show again" else "Not interested", focused && state.titleButton == 2, glyph = if (state.isHidden(title)) "undo" else "hide", primary = false) { state.titleButton = 2; state.key(ShellKey.Ok, true) }
} }
} }
} }

View file

@ -60,6 +60,10 @@ class ShellState(
val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0) val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0)
val view: RememberedView get() = views.getOrPut(person.key to route) { RememberedView() } val view: RememberedView get() = views.getOrPut(person.key to route) { RememberedView() }
/** Screen options share the same per-person memory as their root's focus and scroller. */
fun rememberedView(destination: String): RememberedView =
views.getOrPut(person.key to destination) { RememberedView() }
/** External intents and upstream pops remain authoritative over the screen. */ /** External intents and upstream pops remain authoritative over the screen. */
fun enterDestination(destination: String) { fun enterDestination(destination: String) {
route = destination route = destination

View file

@ -336,5 +336,9 @@ object WolfskinTokens {
val sheetRadioRing = 1.5.dp val sheetRadioRing = 1.5.dp
val skeletonNameWidth = 130.0.dp val skeletonNameWidth = 130.0.dp
val skeletonNameHeight = 17.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)
} }
} }

View file

@ -92,7 +92,7 @@ class DiscoverCaptureTest {
@Test fun st032_restore() = capture("st-032") { state -> hidden(state); state.overlay = DiscoverOverlayState.Restore(state.snapshot.picks[7]) } @Test fun st032_restore() = capture("st-032") { state -> hidden(state); state.overlay = DiscoverOverlayState.Restore(state.snapshot.picks[7]) }
@Test fun st033_failedBadge() = capture("st-033") { state -> @Test fun st033_failedBadge() = capture("st-033") { state ->
val lane = state.lanes.first { it.id == "pk-movie" } val lane = state.lanes.first { it.id == "pk-movie" }
state.requests[lane.titles[1].key] = DiscoverRequestResult(RequestPhase.Receipt, "Justin", "Failed") state.requests[lane.titles[1].key] = listOf(DiscoverRequestResult(RequestPhase.Receipt, "Justin", "Failed", requester = "justin"))
state.focus(state.lanes.indexOf(lane) + 2, 1) state.focus(state.lanes.indexOf(lane) + 2, 1)
} }
@Test fun st034_retryTail() = capture("st-034", snapshotTransform = { snapshot -> @Test fun st034_retryTail() = capture("st-034", snapshotTransform = { snapshot ->
@ -136,7 +136,7 @@ class DiscoverCaptureTest {
if (phase == RequestPhase.Receipt) { if (phase == RequestPhase.Receipt) {
val sheet = (state.overlay as DiscoverOverlayState.Request).state val sheet = (state.overlay as DiscoverOverlayState.Request).state
val receipt = DiscoverRequestResult(phase, state.shell.person.name, requester = sheet.selectedPerson.key) val receipt = DiscoverRequestResult(phase, state.shell.person.name, requester = sheet.selectedPerson.key)
sheet.result = receipt; state.requests[title.key] = receipt sheet.result = receipt; state.requests[title.key] = listOf(receipt)
} }
} }
} }

View file

@ -69,7 +69,7 @@ class DiscoverKeysTest {
assertEquals(RequestPhase.Receipt, sheet.phase) assertEquals(RequestPhase.Receipt, sheet.phase)
assertEquals("justin", operations.sent.single().requester) assertEquals("justin", operations.sent.single().requester)
assertEquals("Justin", sheet.result?.actor) assertEquals("Justin", sheet.result?.actor)
assertEquals("Waiting", state.requests.getValue(requireNotNull(state.title).key).stage) assertEquals("Waiting", state.requests.getValue(requireNotNull(state.title).key).single().stage)
} }
@Test fun requestForMindelInNinePresses() { @Test fun requestForMindelInNinePresses() {

View file

@ -225,4 +225,8 @@
--discover-sheetRadioRing: 3.0px; --discover-sheetRadioRing: 3.0px;
--discover-skeletonNameWidth: 260.0px; --discover-skeletonNameWidth: 260.0px;
--discover-skeletonNameHeight: 34.0px; --discover-skeletonNameHeight: 34.0px;
--discover-tailGap: 10.0px;
--discover-tailIcon: 44.0px;
--discover-seeAllGap: 8.0px;
--discover-tailDescription: 24.0px;
} }

View file

@ -1,6 +1,6 @@
b88b3ae2be6865a53076cbdbca8362a94f2cc40561f20ef9864394eb10c097dc discover-tokens.json dd16d79ec484b38c2ac17e8fef9d61e522a9c72d934a104d0492bf46c652abae discover-tokens.json
fcff646d59fb39e859c34c05037e05a7fb7583de2fe07315ec0176cf4d597980 generate_tokens.py fcff646d59fb39e859c34c05037e05a7fb7583de2fe07315ec0176cf4d597980 generate_tokens.py
408b7c3f4db2028deda97ac0570a57a4da4877917fd0980b2bfa5afe8f8fa790 shell-tokens.json 408b7c3f4db2028deda97ac0570a57a4da4877917fd0980b2bfa5afe8f8fa790 shell-tokens.json
3240addb082c7b681b675e9008618a7bfe4c2fde5e7970d702c2ac759eac9ffe src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt 5c2d8555644b5b399f8d2be83ae6e1d0f4481c2fb2cb48e549412119e2aa62ca src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
a639780189f0426d456f23dc0ba18c45581702b8d12a3f59430d71b912e4315d tokens.css 403dc4dc8a151c6b456efa9f7f1a7776984a0b4315af62b130168728bc3f3682 tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json