From 03ff22ea86ea22d9b6d7b83e0bb122ae93cae175 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 5 Nov 2026 17:37:39 +0100 Subject: [PATCH] Preserve request outcomes and align Discover shelf windows --- .../tv/ConductarrDiscoverOperations.kt | 6 +- wolfskin-design/discover-tokens.json | 29 ++++ .../wolfskin/design/DiscoverCollection.kt | 4 +- .../wolfskin/design/DiscoverFixture.kt | 2 +- .../wolfskin/design/DiscoverLaneProjection.kt | 41 ++++++ .../wolfskin/design/DiscoverModel.kt | 3 +- .../wolfskin/design/DiscoverShelf.kt | 72 ++++++---- .../wolfskin/design/DiscoverState.kt | 125 ++++++++++-------- .../design/DiscoverTitleDestination.kt | 12 +- .../wolfskin/design/ShellState.kt | 4 + .../wolfskin/design/WolfskinTokens.kt | 4 + .../wolfskin/design/DiscoverCaptureTest.kt | 4 +- .../wolfskin/design/DiscoverKeysTest.kt | 2 +- wolfskin-design/tokens.css | 4 + wolfskin-design/tokens.sha256 | 6 +- 15 files changed, 226 insertions(+), 92 deletions(-) create mode 100644 wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt index 0e8556d8..a98814ec 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt @@ -28,8 +28,10 @@ class ConductarrDiscoverOperations( require(title.type != "tv" || !title.seasons.isNullOrEmpty()) { "Whole-series season metadata is required" } val body = RequestIntentBody( mediaType = if (title.type == "movie") RequestIntentBody.MediaType.MOVIE else RequestIntentBody.MediaType.TV, - recommendationProfile = intent.profile, requester = intent.requester, - seasons = title.seasons?.map(Int::toLong), source = RequestIntentBody.Source.RECOMMENDATION, tmdbId = title.tmdb, + recommendationProfile = intent.profile.takeIf { intent.fromRecommendation }, requester = intent.requester, + 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))) { is MutationResult.Success -> result.value.outcome() diff --git a/wolfskin-design/discover-tokens.json b/wolfskin-design/discover-tokens.json index 0b711725..df99feec 100644 --- a/wolfskin-design/discover-tokens.json +++ b/wolfskin-design/discover-tokens.json @@ -304,6 +304,35 @@ "px": 34, "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" } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCollection.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCollection.kt index 9242408c..74ee566a 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCollection.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCollection.kt @@ -76,8 +76,8 @@ fun DiscoverCollection(state: DiscoverState, collection: DiscoverCollectionState val item = collection.items.getOrNull(collection.index) Caption( 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")), - true, Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(D.captionWidth), + if (item != null) buildDiscoverCaptionFacts(item, state, false) else listOf(CaptionFact("The last read only repeated titles")), + true, Modifier.offset(L.safeSide + K.posterPitch * collection.column, K.posterH + L.captionOffset).width(D.captionWidth), ) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverFixture.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverFixture.kt index 7c85ee4f..0b8d52e7 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverFixture.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverFixture.kt @@ -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") } 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 { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt new file mode 100644 index 00000000..bda06cdc --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt @@ -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, filter: String, hidden: Map): List { + fun matches(item: DiscoverTitle) = filter == "all" || item.type == filter + val seen = heroes.mapTo(mutableSetOf()) { it.key } + val result = mutableListOf() + fun take(items: List, 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 +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt index f95c10d4..3fc3b296 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt @@ -44,6 +44,7 @@ data class DiscoverSnapshot( val hidden: List = emptyList(), val status: DiscoverStatus = DiscoverStatus.NeedsSignIn, val savedAt: String? = null, + val reusesFixtureLanes: Boolean = false, ) /** 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. */ -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. */ data class DiscoverRequestResult( diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt index 50f31ed3..6392ffa8 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt @@ -2,6 +2,8 @@ package org.justinvisser.wolfskin.design 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.border 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.Color import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.zIndex import androidx.tv.material3.Text -import kotlinx.coroutines.delay import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C 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 height = if (services) K.entryH else K.posterH 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 - 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}")) { 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)) { 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) @@ -80,8 +99,8 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { val item = lane.titles.getOrNull(column) 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) 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")), - true, Modifier.offset(L.safeSide, L.rowHeadingBand + K.posterH + L.captionOffset).width(D.captionWidth), + 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 + 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) } } - val result = state.requests[title.key] - if (state.shell.person.key in title.heldBy || result != null) { + val receipts = state.requests[title.key].orEmpty() + 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) - .background(if (result == null) 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) { - 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)) + .background(if (held) C.ok else C.badgeRest, CircleShape) + .border(if (held) WolfskinTokens.Shell.zero else D.badgeRing, if (failed) C.fail else C.accent, CircleShape), contentAlignment = Alignment.Center) { + 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) .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(L.buttonGap, 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)) - Text(when (lane.tail) { DiscoverTail.SeeAll -> "See all"; DiscoverTail.Retry -> "Try again"; else -> "Show more" }, color = C.fg, style = WolfskinTokens.Type.listTitle) - 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) + 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) } } -internal fun captionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): List = buildList { +internal fun buildDiscoverCaptionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): List = buildList { if (hidden) add(CaptionFact("Hidden from your picks", true)) 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")) + val receipts = state.requests[title.key].orEmpty() if (state.shell.person.key in title.heldBy) add(CaptionFact("In your library", true)) - else if (state.requests.containsKey(title.key)) { - val request = state.requests.getValue(title.key) - 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 $recipient · ${request.stage}", true)) - } else title.heldBy.firstOrNull()?.let { key -> add(CaptionFact("In ${state.shell.people.firstOrNull { it.key == key }?.name ?: key}’s library", true)) } + else if (receipts.isNotEmpty()) { + 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() } + add(CaptionFact("Requested for $recipients · ${receipts.first().stage}", true)) + } else if (title.heldBy.isNotEmpty()) { + val names = title.heldBy.joinToString(" and ") { key -> state.shell.people.firstOrNull { it.key == key }?.name ?: key } + add(CaptionFact("In $names’s library", true)) + } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt index 71c30ed2..c0c1053e 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlin.math.ceil /** One person owns a Discover view; the shell retains its root list and card memory. */ class DiscoverState( @@ -23,8 +22,16 @@ class DiscoverState( ) { var snapshot by mutableStateOf(initial) private set - var filter by mutableStateOf("all") - var slide by mutableStateOf(0) + private var serviceFilter by mutableStateOf("movie") + 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(null) var service by mutableStateOf(null) var collection by mutableStateOf(null) @@ -36,7 +43,8 @@ class DiscoverState( var toast by mutableStateOf(null) val observedRatings = mutableSetOf() var plateKey by mutableStateOf(null) - val requests = mutableStateMapOf() + val requests = mutableStateMapOf>() + private val requestSheets = mutableMapOf, RequestSheetState>() private val hidden = mutableStateMapOf().apply { initial.hidden.forEach { put(it.key, it) } } private var armedCard: String? = null private var revision by mutableStateOf(0) @@ -48,11 +56,15 @@ class DiscoverState( private var cachedHeroesFilter = "" private var cachedHeroes = emptyList() private val dismissalMutex = Mutex() - private var dismissalVersion = 0 + private val dismissalVersions = mutableMapOf() private var rootFilter = "all" 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 get() { if (cachedHeroesRevision != revision || cachedHeroesFilter != filter) { 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 } - val hero: DiscoverTitle? get() = heroes.getOrNull(slide.coerceAtMost((heroes.size - 1).coerceAtLeast(0))) + val hero: DiscoverTitle? get() = heroes.getOrNull(slide) val lanes: List get() { if (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 focusedRow: Int get() = shell.view.row.coerceIn(0, (rowCount - 1).coerceAtLeast(0)) 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 isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null fun focus(row: Int, column: Int = 0) { 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) { @@ -113,6 +136,7 @@ class DiscoverState( return true } 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 (isCard && key == ShellKey.Ok) { val identity = "$rowKey:$column" @@ -132,8 +156,8 @@ class DiscoverState( } if (title != null) { titleKey(key); return true } 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.Down -> focus((focusedRow + 1).coerceAtMost(rowCount - 1)) + ShellKey.Up -> moveRow(-1) + ShellKey.Down -> moveRow(1) ShellKey.Left, ShellKey.Right -> { val direction = if (key == ShellKey.Left) -1 else 1 if (focusedRow == 0) slide = (slide + direction).coerceIn(0, (heroes.size - 1).coerceAtLeast(0)) @@ -142,7 +166,7 @@ class DiscoverState( ShellKey.Ok -> when { !isSignedIn || snapshot.status == DiscoverStatus.Unavailable -> refresh() 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 } @@ -167,12 +191,16 @@ class DiscoverState( } private fun titleKey(key: ShellKey) { + if (!canRequest) { + if (key == ShellKey.Up) shell.focusNav(shell.currentTab) else if (key == ShellKey.Ok) refresh() + return + } when (key) { ShellKey.Up -> shell.focusNav(shell.currentTab) ShellKey.Left -> titleButton = (titleButton - 1).coerceAtLeast(0) ShellKey.Right -> titleButton = (titleButton + 1).coerceAtMost(if (title?.let(::isPick) == true) 2 else 1) 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) 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) { - if (!isSignedIn || snapshot.status == DiscoverStatus.Unavailable) { refresh(); return } - overlay = DiscoverOverlayState.Request(RequestSheetState(item, shell.person, shell.people, other, phase)) + if (!canRequest) { refresh(); return } + 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) { @@ -211,65 +243,54 @@ class DiscoverState( } 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 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) 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) { + if (!canRequest) { refresh(); return } 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 { - val success = dismissalMutex.withLock { operations.hide(shell.person.key, item) } - if (!success && dismissalVersion == version) { hidden.remove(item.key); revision++; overlay = null; toast = "Couldn’t hide this pick. Try again." } + 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." } } } fun restore(item: DiscoverTitle) { + if (!canRequest) { refresh(); return } 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 { - val success = dismissalMutex.withLock { operations.restore(shell.person.key, item) } - if (!success && dismissalVersion == version) { hidden[item.key] = item; revision++; toast = "Couldn’t restore this pick. Try again." } + 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." } } } 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 buildLanes(): List { - service?.let { return serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() } } - val seen = heroes.mapTo(mutableSetOf()) { it.key } - val result = mutableListOf() - fun take(items: List, 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 buildLanes(): List = service?.let { + serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() } + } ?: projectDiscoverLanes(snapshot, heroes, filter, hidden) } private fun Int?.orZero(): Int = this ?: 0 + +private val DISCOVER_FILTERS = listOf("all", "movie", "tv") diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverTitleDestination.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverTitleDestination.kt index fa4b7246..c01f0e7b 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverTitleDestination.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverTitleDestination.kt @@ -35,12 +35,14 @@ fun DiscoverTitleDestination(state: DiscoverState, modifier: Modifier = Modifier Text(title.overview, color = C.muted, style = WolfskinTokens.Type.body, maxLines = 3) Spacer(Modifier.height(D.heroOverviewGap)) Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - val receipt = state.requests[title.key]?.takeIf { it.requester == state.shell.person.key } - 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) + if (!state.canRequest) DiscoverButton("Try again", focused) { state.refresh() } else { + val receipt = state.requests[title.key]?.firstOrNull { it.requester == state.shell.person.key } + 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) } } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt index 87f93fea..d429b413 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -60,6 +60,10 @@ class ShellState( val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0) 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. */ fun enterDestination(destination: String) { route = destination diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index 4150bc1c..8a70e3c9 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -336,5 +336,9 @@ object WolfskinTokens { 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) } } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt index eda07393..2f7edb53 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt @@ -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 st033_failedBadge() = capture("st-033") { state -> 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) } @Test fun st034_retryTail() = capture("st-034", snapshotTransform = { snapshot -> @@ -136,7 +136,7 @@ class DiscoverCaptureTest { if (phase == RequestPhase.Receipt) { val sheet = (state.overlay as DiscoverOverlayState.Request).state 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) } } } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt index 24e5328e..80b3bc8c 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt @@ -69,7 +69,7 @@ class DiscoverKeysTest { assertEquals(RequestPhase.Receipt, sheet.phase) assertEquals("justin", operations.sent.single().requester) 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() { diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index 1542a58b..9a09e8f3 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -225,4 +225,8 @@ --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; } diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index 674e6f8f..096671f2 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,6 +1,6 @@ -b88b3ae2be6865a53076cbdbca8362a94f2cc40561f20ef9864394eb10c097dc discover-tokens.json +dd16d79ec484b38c2ac17e8fef9d61e522a9c72d934a104d0492bf46c652abae discover-tokens.json fcff646d59fb39e859c34c05037e05a7fb7583de2fe07315ec0176cf4d597980 generate_tokens.py 408b7c3f4db2028deda97ac0570a57a4da4877917fd0980b2bfa5afe8f8fa790 shell-tokens.json -3240addb082c7b681b675e9008618a7bfe4c2fde5e7970d702c2ac759eac9ffe src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -a639780189f0426d456f23dc0ba18c45581702b8d12a3f59430d71b912e4315d tokens.css +5c2d8555644b5b399f8d2be83ae6e1d0f4481c2fb2cb48e549412119e2aa62ca src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +403dc4dc8a151c6b456efa9f7f1a7776984a0b4315af62b130168728bc3f3682 tokens.css 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json