Preserve request outcomes and align Discover shelf windows
This commit is contained in:
parent
90e956b157
commit
03ff22ea86
15 changed files with 226 additions and 92 deletions
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue