Match Discover captions and catalogue collection states

This commit is contained in:
justinzeus 2026-11-05 16:50:38 +01:00
parent 7e8e232d47
commit 4b28b4f044
25 changed files with 486 additions and 74 deletions

View file

@ -44,11 +44,26 @@ class ConductarrDiscoverOperations(
}
}
override suspend fun hide(profile: String, title: DiscoverTitle): Boolean = client.mutate(TvRoutes.hide(profile,
override suspend fun hide(profile: String, title: DiscoverTitle): Boolean {
val result = client.mutate(TvRoutes.hide(profile,
RecommendationHideInputBody(if (title.type == "movie") RecommendationHideInputBody.MediaType.MOVIE else RecommendationHideInputBody.MediaType.TV,
title = title.title, tmdbId = title.tmdb, year = title.year?.toLong()))) is MutationResult.Success
title = title.title, tmdbId = title.tmdb, year = title.year?.toLong())))
return result is MutationResult.Success || observedHidden(profile, title) == true
}
override suspend fun restore(profile: String, title: DiscoverTitle): Boolean = client.mutate(TvRoutes.restore(profile, title.tmdb, title.mediaType())) is MutationResult.Success
override suspend fun restore(profile: String, title: DiscoverTitle): Boolean {
val result = client.mutate(TvRoutes.restore(profile, title.tmdb, title.mediaType()))
return result is MutationResult.Success || observedHidden(profile, title) == false
}
private suspend fun observedHidden(profile: String, title: DiscoverTitle): Boolean? {
return when (val result = client.read(TvRoutes.dismissals(profile), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) {
is ReadResult.Value -> result.saved.value?.dismissals?.any {
it.tmdbId == title.tmdb && it.mediaType.name == title.mediaType().name
}
is ReadResult.Failure -> null
}
}
override suspend fun refresh(): DiscoverSnapshot = refreshSnapshot()
}
@ -56,4 +71,5 @@ private fun DiscoverTitle.mediaType(): MediaType = if (type == "movie") MediaTyp
private fun RequestReceipt.outcome(): DiscoverRequestResult = DiscoverRequestResult(
phase = if (state == RequestReceipt.State.PENDING) RequestPhase.Pending else if (state == RequestReceipt.State.RETIRED) RequestPhase.Refused else RequestPhase.Receipt,
actor = actor.name,
requester = requester,
)

View file

@ -239,6 +239,53 @@
"px": 24,
"dp": 12,
"source": "locked index.html:202"
},
"captionFactsGap": {
"$type": "dimension",
"px": 18,
"dp": 9,
"source": "locked index.html:154"
},
"captionSeparatorOpacity": {
"$type": "number",
"$value": 0.4,
"source": "locked index.html:1207"
},
"badgeRing": {
"$type": "dimension",
"px": 3,
"dp": 1.5,
"source": "locked index.html:144"
},
"badgeIcon": {
"$type": "dimension",
"px": 28,
"dp": 14,
"source": "locked index.html:142"
},
"entryPaddingX": {
"$type": "dimension",
"px": 22,
"dp": 11,
"source": "locked index.html:149"
},
"entryPaddingY": {
"$type": "dimension",
"px": 20,
"dp": 10,
"source": "locked index.html:149"
},
"entryText": {
"$type": "typography",
"px": 30,
"sp": 15,
"weight": 800,
"letterSpacingEm": -0.01,
"$value": {
"fontSize": "15sp",
"fontWeight": 800
},
"source": "locked index.html:149"
}
}
}

View file

@ -1,21 +1,38 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.Text
/** Accent facts identify availability without colouring the intervening separators. */
data class CaptionFact(val text: String, val accent: Boolean = false)
/** Captions fade without moving the reserved band under the focused poster. */
@Composable
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier) {
val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "caption-fade")
Column(modifier.graphicsLayer { this.alpha = alpha }) {
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1)
Text(facts, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1)
fun Caption(title: String, facts: List<CaptionFact>, visible: Boolean, modifier: Modifier = Modifier) {
val alpha = remember { Animatable(0f) }
LaunchedEffect(visible, title) {
alpha.snapTo(0f)
if (visible) alpha.animateTo(1f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
}
Column(modifier.graphicsLayer { this.alpha = alpha.value }) {
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
Row(Modifier.clipToBounds(), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Discover.captionFactsGap)) {
facts.forEachIndexed { index, fact ->
if (index > 0) Text("·", color = WolfskinTokens.Color.muted.copy(alpha = WolfskinTokens.Color.muted.alpha * WolfskinTokens.Discover.captionSeparatorOpacity), style = WolfskinTokens.Type.caption, maxLines = 1)
Text(fact.text, color = if (fact.accent) WolfskinTokens.Color.accentText else WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1)
}
}
}
}

View file

@ -0,0 +1,86 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.tv.material3.Text
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
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
/** Catalogue See all shares poster anatomy with shelves and never introduces a page selector. */
@Composable
fun DiscoverCollection(state: DiscoverState, collection: DiscoverCollectionState, modifier: Modifier = Modifier) {
val list = state.shell.view.listState
val density = LocalDensity.current
val headerRows = if (collection.hasChips) 2 else 1
LaunchedEffect(collection.row, collection.chipsFocused, state.shell.navFocused) {
if (!state.shell.navFocused) {
if (collection.row == 0 || collection.chipsFocused) list.animateScrollToItem(0)
else list.animateScrollToItem(collection.row + headerRows, -with(density) { L.pinnedLine.roundToPx() })
}
}
LazyColumn(state = list, contentPadding = PaddingValues(bottom = WolfskinTokens.Shell.stageHeight - L.pinnedLine), modifier = modifier.fillMaxSize()) {
item(key = "collection-header", contentType = "header") {
Row(Modifier.fillMaxWidth().height(L.row_lhead).padding(start = L.safeSide, top = D.headerTop), horizontalArrangement = Arrangement.spacedBy(D.headerGap), verticalAlignment = Alignment.CenterVertically) {
Avatar(state.shell.person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader)
Text(collection.lane.title, color = C.fg, style = WolfskinTokens.Type.screenTitle)
Text("${collection.items.size} titles", color = C.muted, style = WolfskinTokens.Type.nav)
}
}
if (collection.hasChips) item(key = "collection-chips", contentType = "chips") {
Row(Modifier.fillMaxWidth().height(L.row_chips).padding(start = L.safeSide, top = D.chipTop), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
listOf("All", "Movies", "Series").forEachIndexed { index, label ->
val focused = collection.chipsFocused && collection.chip == index && state.contentFocused
val selected = collection.filter == listOf("all", "movie", "tv")[index]
Box(Modifier.height(D.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, RoundedCornerShape(WolfskinTokens.Radius.chip))
.border(if (!focused && selected) WolfskinTokens.Focus.ringCardRing / 2 else WolfskinTokens.Shell.zero, if (!focused && selected) C.accent else C.chipRest.copy(alpha = 0f), RoundedCornerShape(WolfskinTokens.Radius.chip))
.clickable { collection.chipsFocused = true; collection.chip = index; collection.key(ShellKey.Ok, true) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) {
Text(label, color = if (focused) C.onFocus else if (selected) C.accentText else C.fg, style = WolfskinTokens.Type.label)
}
}
}
}
items(collection.rowCount, key = { "grid-$it" }, contentType = { "poster" }) { row ->
val offset = row * WolfskinTokens.Behaviour.gridColumns.toInt()
Box(Modifier.fillMaxWidth().height(K.posterH + K.posterCaptionBand + L.gridRowGap)) {
Row(Modifier.offset(x = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
if (offset >= collection.items.size) DiscoverTailCard(collection.lane, state.contentFocused && collection.row == row) { collection.focus(row, 0); state.refresh() }
else collection.items.subList(offset, minOf(offset + 6, collection.items.size)).forEachIndexed { column, item ->
DiscoverPoster(state, item, state.contentFocused && !collection.chipsFocused && collection.row == row && collection.column == column, false,
Modifier.clickable { collection.focus(row, column); state.openTitle(item) })
}
}
if (state.contentFocused && !collection.chipsFocused && collection.row == row) {
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),
)
}
}
}
}
}

View file

@ -0,0 +1,63 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.getValue
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/** See all retains a continuous six-column grid and its exact origin beneath the title stack. */
class DiscoverCollectionState(
val shell: ShellState,
val lane: DiscoverLane,
private val openTitle: (DiscoverTitle) -> Unit,
private val refresh: () -> Unit,
private val pop: () -> Unit,
) {
var filter by mutableStateOf("all")
var chipsFocused by mutableStateOf(false)
var chip by mutableStateOf(0)
private var armed: Int? = null
private val filteredItems = derivedStateOf { if (filter == "all") lane.fullTitles else lane.fullTitles.filter { it.type == filter } }
val items: List<DiscoverTitle> get() = filteredItems.value
val hasChips: Boolean = lane.fullTitles.any { it.type == "movie" } && lane.fullTitles.any { it.type == "tv" }
val row: Int get() = shell.view.row
val column: Int get() = shell.view.cards["grid-$row"] ?: 0
val index: Int get() = row * 6 + column
val hasTail: Boolean get() = lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)
val rowCount: Int get() = (items.size + 5) / 6 + if (hasTail) 1 else 0
fun focus(row: Int, column: Int) { shell.navFocused = false; chipsFocused = false; shell.view.row = row; shell.view.cards["grid-$row"] = column }
fun key(key: ShellKey, down: Boolean): Boolean {
if (key == ShellKey.Ok && !chipsFocused) {
if (down) armed = index else {
if (armed == index) { val item = items.getOrNull(index); if (item != null) openTitle(item) else if (hasTail) refresh() }
armed = null
}
return true
}
if (!down) return true
armed = null
if (key == ShellKey.Back) { pop(); return true }
if (chipsFocused) {
when (key) {
ShellKey.Up -> shell.focusNav(shell.currentTab)
ShellKey.Down -> chipsFocused = false
ShellKey.Left -> chip = (chip - 1).coerceAtLeast(0)
ShellKey.Right -> chip = (chip + 1).coerceAtMost(2)
ShellKey.Ok -> { filter = listOf("all", "movie", "tv")[chip]; focus(0, 0); chipsFocused = true }
else -> Unit
}
} else when (key) {
ShellKey.Up -> if (row > 0) focus(row - 1, column.coerceAtMost(rowSize(row - 1) - 1)) else if (hasChips) chipsFocused = true else shell.focusNav(shell.currentTab)
ShellKey.Down -> if (row < rowCount - 1) focus(row + 1, column.coerceAtMost(rowSize(row + 1) - 1))
ShellKey.Left -> focus(row, (column - 1).coerceAtLeast(0))
ShellKey.Right -> focus(row, (column + 1).coerceAtMost(rowSize(row) - 1))
else -> Unit
}
return true
}
private fun rowSize(row: Int): Int = if (hasTail && row == rowCount - 1) 1 else (items.size - row * 6).coerceIn(1, 6)
}

View file

@ -22,17 +22,18 @@ class DiscoverFixture(json: String, private val artPath: (String) -> String = {
.filter { it.optBoolean("played") }
val groups = linkedMapOf<String, Pair<String, MutableList<DiscoverTitle>>>()
picks.sortedBy { if (it.type == "movie") 0 else 1 }.forEach { pick ->
played.map { it to it.getJSONArray("genres").strings().intersect(pick.genres.toSet()).size }
played.map { it to normalizedGenres(it.getJSONArray("genres").strings()).intersect(normalizedGenres(pick.genres)).size }
.filter { it.second > 0 }.sortedByDescending { it.second }.take(2).forEach { (seed, _) ->
groups.getOrPut(identity(seed)) { seed.getString("title") to mutableListOf() }.second.add(pick)
}
}
val lanes = services.mapIndexed { index, service ->
DiscoverLane("new-$index", "New on ${service.name}", rotate(pool, index * 7).take(12).sortedByDescending { it.year })
} + listOf(DiscoverLane("trending", "Trending", pool.take(20), pool.size)) +
val source = rotate(pool, index * 7).sortedByDescending { it.year }
DiscoverLane("new-$index", "New on ${service.name}", source.take(12), fullTitles = source)
} + listOf(DiscoverLane("trending", "Trending", pool.take(20), pool.size, fullTitles = pool)) +
listOf("Comedy", "Animation", "Science Fiction", "Horror", "Romance").map { genre ->
val items = pool.filter { genre in it.genres && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.type == "movie") }
DiscoverLane("genre-$genre", genre, items.take(20), items.size)
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)
}
@ -70,6 +71,9 @@ class DiscoverFixture(json: String, private val artPath: (String) -> String = {
private fun identity(json: JSONObject): String = "${json.getString("type")}:${json.optLong("tmdbId", json.getString("id").hashCode().toLong().let { if (it < 0) -it else it })}"
private fun <T> rotate(items: List<T>, offset: Int): List<T> = if (items.isEmpty()) items else items.drop(offset % items.size) + items.take(offset % items.size)
private fun normalizedGenres(genres: List<String>): Set<String> = genres.flatMap {
when (it) { "Sci-Fi & Fantasy" -> listOf("Science Fiction", "Fantasy"); "Action & Adventure" -> listOf("Action", "Adventure"); "Sci-Fi" -> listOf("Science Fiction"); else -> listOf(it) }
}.toSet()
}
private fun JSONArray.objects(): List<JSONObject> = List(length()) { getJSONObject(it) }

View file

@ -55,6 +55,7 @@ data class DiscoverLane(
val tail: DiscoverTail = if (total > titles.size) DiscoverTail.SeeAll else DiscoverTail.None,
val pick: Boolean = false,
val hidden: Boolean = false,
val fullTitles: List<DiscoverTitle> = titles,
)
/** Caller identity is deliberately absent: the authenticated client supplies the actor. */
@ -65,6 +66,7 @@ data class DiscoverRequestResult(
val phase: RequestPhase,
val actor: String? = null,
val stage: String = "Waiting",
val requester: String? = null,
)
enum class RequestPhase { Choose, LoadingRecipients, RecipientsUnavailable, Sending, Receipt, NotConfirmed, Pending, Refused }

View file

@ -39,7 +39,6 @@ fun DiscoverOverlay(state: DiscoverState) {
when (overlay) {
is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state)
is DiscoverOverlayState.Hidden -> {
Text("Hidden from your picks", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.")
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title) }
@ -71,15 +70,12 @@ private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
Text("${sheet.title.title} · ${sheet.title.typeWord}${sheet.title.year?.let { " · $it" }.orEmpty()}", color = C.muted, style = WolfskinTokens.Type.body)
if (state.isSignedIn) DiscoverRatings(sheet.title.ratings)
when (sheet.phase) {
RequestPhase.Choose -> RequestChoices(state, sheet)
RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet)
RequestPhase.LoadingRecipients -> {
Text("For", color = C.dim, style = WolfskinTokens.Type.label)
repeat(3) { Box(Modifier.fillMaxWidth().height(L.sheetRow).background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))) }
DiscoverButton("Close", true) { state.overlay = null }
}
RequestPhase.Sending -> {
ReceiptPanel("Request", "Checking the request.")
}
else -> RequestOutcome(state, sheet)
}
}
@ -143,8 +139,14 @@ private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) {
@Composable
private fun ReceiptPanel(title: String, text: String, color: androidx.compose.ui.graphics.Color = C.fg, stage: String? = null) {
Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(WolfskinTokens.Shell.panelPaddingX, WolfskinTokens.Shell.panelPaddingY), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.panelGap)) {
Row(horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
Glyph(if (title == "Requested" || title == "Hidden from your picks" || title.startsWith("Adding")) "check" else if (title == "Request unresolved") "clock" else "alert", color, Modifier.size(WolfskinTokens.Shell.panelIcon))
Text(title, color = color, style = WolfskinTokens.Type.stateTitle.copy(fontWeight = FontWeight.Bold))
}
Text(text, color = C.muted, style = WolfskinTokens.Type.body)
if (stage != null) Text(stage, color = C.muted, style = WolfskinTokens.Type.label)
if (stage != null) Row(horizontalArrangement = Arrangement.spacedBy(L.navBlobAvatarPad), verticalAlignment = Alignment.CenterVertically) {
Glyph("clock", C.muted, Modifier.size(L.buttonIcon))
Text(stage, color = C.muted, style = WolfskinTokens.Type.label)
}
}
}

View file

@ -22,6 +22,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
@ -38,13 +39,22 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
@Composable
fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
val shell = state.shell
val input = remember(state) { state::key }
DisposableEffect(state) {
shell.contentInput = state::key
onDispose { shell.contentInput = null; shell.contentOverlayVisible = false }
shell.contentInput = input
onDispose { if (shell.contentInput === input) { shell.contentInput = null; shell.contentOverlayVisible = false } }
}
LaunchedEffect(state.overlay) { shell.contentOverlayVisible = state.overlay != null }
LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } }
val focusedTitle = if (state.collection != null && state.title == null) state.collection?.let { it.items.getOrNull(it.index) } else state.focusedLane?.titles?.getOrNull(state.column)
LaunchedEffect(focusedTitle?.key, state.contentFocused) {
state.plateKey = null
if (state.contentFocused && focusedTitle != null) {
if (focusedTitle.key !in state.observedRatings) kotlinx.coroutines.delay(WolfskinTokens.Motion.ratingDwell.toLong())
state.observedRatings.add(focusedTitle.key); state.plateKey = focusedTitle.key
}
}
if (state.title != null) { DiscoverTitleDestination(state, modifier); return }
state.collection?.let { DiscoverCollection(state, it, modifier); return }
val density = LocalDensity.current
val list = shell.view.listState
LaunchedEffect(state.focusedRow, shell.navFocused) {
@ -52,14 +62,14 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
val row = state.focusedRow
val alreadyFits = state.service != null && row <= 2
if (row <= 1 || alreadyFits) list.animateScrollToItem(0)
else list.animateScrollToItem(row, -with(density) { L.pinnedLine.roundToPx() })
else list.animateScrollToItem(row + if (state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) 1 else 0, -with(density) { L.pinnedLine.roundToPx() })
}
}
Box(modifier.fillMaxSize().testTag("discover-screen")) {
if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { DiscoverBackdrop(it, modifier = Modifier.offset(y = -with(density) { list.firstVisibleItemScrollOffset.toDp() })) }
LazyColumn(state = list, contentPadding = PaddingValues(bottom = WolfskinTokens.Shell.stageHeight - L.pinnedLine), modifier = Modifier.fillMaxSize()) {
item(key = "discover-header", contentType = if (state.hasHero && state.service == null) "hero" else "header") {
if (state.hasHero && state.service == null) DiscoverHero(state) else DiscoverHeader(state.service?.name ?: "Discover", if (state.service != null) "General interest" else "")
if (state.hasHero && state.service == null) DiscoverHero(state) else DiscoverHeader(state.shell.person, state.service?.name ?: "Discover", if (state.service != null) "General interest" else "")
}
item(key = "discover-chips", contentType = "chips") {
if (state.snapshot.status !in setOf(DiscoverStatus.NeedsSignIn, DiscoverStatus.Unavailable, DiscoverStatus.Loading)) DiscoverChips(state)
@ -77,8 +87,9 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
/** Header and type chips use the same safe area as the shell. */
@Composable
private fun DiscoverHeader(title: String, subtitle: String) {
private fun DiscoverHeader(person: ShellPerson, title: String, subtitle: String) {
Row(Modifier.fillMaxWidth().height(L.row_lhead).padding(start = L.safeSide, top = D.headerTop), horizontalArrangement = Arrangement.spacedBy(D.headerGap), verticalAlignment = Alignment.CenterVertically) {
Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader)
Text(title, color = C.fg, style = WolfskinTokens.Type.screenTitle)
if (subtitle.isNotEmpty()) Text(subtitle, color = C.muted, style = WolfskinTokens.Type.nav)
}
@ -107,9 +118,9 @@ private fun DiscoverHero(state: DiscoverState) {
Row(verticalAlignment = Alignment.CenterVertically) {
DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) }
Row(Modifier.padding(start = D.dotMargin), horizontalArrangement = Arrangement.spacedBy(D.dotGap), verticalAlignment = Alignment.CenterVertically) {
Text(if (state.contentFocused && state.focusedRow == 0) "‹" else "", Modifier.width(D.dotChevron), color = C.fg, style = WolfskinTokens.Type.sheetTitle)
Glyph("left", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron))
state.heroes.forEachIndexed { index, _ -> Box(Modifier.size(if (index == state.slide) D.dotActive else D.dotSize, D.dotSize).background(if (index == state.slide) C.fg else C.fg.copy(alpha = .3f), CircleShape)) }
Text(if (state.contentFocused && state.focusedRow == 0) "›" else "", Modifier.width(D.dotChevron), color = C.fg, style = WolfskinTokens.Type.sheetTitle)
Glyph("right", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron))
}
}
}

View file

@ -60,8 +60,9 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
.background(Brush.linearGradient(listOf(Color.hsl(service.hue, .30f, .26f), Color.hsl(service.hue, .30f, .12f))), RoundedCornerShape(WolfskinTokens.Radius.card))
.clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.Center) {
Text(service.name, style = WolfskinTokens.Type.listTitle, color = C.fg)
.border(WolfskinTokens.Focus.ringCardRing / 2, C.border, RoundedCornerShape(WolfskinTokens.Radius.card))
.clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) {
Text(service.name, Modifier.padding(start = D.entryPaddingX, bottom = D.entryPaddingY), style = D.entryText, color = C.fg)
}
} else {
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->
@ -77,10 +78,11 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
}
if (!services && active) {
val item = lane.titles.getOrNull(column)
Column(Modifier.offset(L.safeSide, L.rowHeadingBand + K.posterH + L.captionOffset).width(D.captionWidth)) {
Text(item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more", color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(if (item != null) captionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1)
}
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),
)
}
}
}
@ -88,11 +90,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
/** Only the focused poster reads the ratings plate; cached observations paint immediately. */
@Composable
fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean, hidden: Boolean, modifier: Modifier = Modifier) {
var plate by remember(title.key) { mutableStateOf(false) }
val observed = remember(state) { mutableSetOf<String>() }
LaunchedEffect(focused, title.key) {
if (!focused) plate = false else { if (title.key !in observed) delay(WolfskinTokens.Motion.ratingDwell.toLong()); observed.add(title.key); plate = true }
}
val plate = focused && state.plateKey == title.key
Box(modifier.size(K.posterW, K.posterH).zIndex(if (focused) 1f else 0f)
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)) {
@ -108,8 +106,8 @@ fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean,
if (state.shell.person.key in title.heldBy || result != null) {
Box(Modifier.align(Alignment.BottomEnd).offset(-L.badgeInset, -L.badgeInset).size(L.badge)
.background(if (result == null) C.ok else C.badgeRest, CircleShape)
.border(WolfskinTokens.Focus.ringCardRing, if (result?.stage == "Failed") C.fail else C.accent, CircleShape), contentAlignment = Alignment.Center) {
Text(if (result == null) "✓" else if (result.stage == "Failed") "!" else "◷", color = if (result == null) C.onFocus else if (result.stage == "Failed") C.fail else C.accent, style = WolfskinTokens.Type.button)
.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))
}
}
}
@ -117,7 +115,7 @@ fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean,
}
@Composable
private fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: () -> Unit) {
internal fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: () -> Unit) {
Column(Modifier.size(K.posterW, K.posterH).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)).border(WolfskinTokens.Focus.ringCardRing / 2, C.border, RoundedCornerShape(WolfskinTokens.Radius.card)).clickable(onClick = onClick).padding(L.buttonGap),
@ -128,11 +126,14 @@ private fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: () -
}
}
private fun captionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): String = buildList {
if (hidden) add("Hidden from your picks")
title.year?.let { add(it.toString()) }
add(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")
if (state.shell.person.key in title.heldBy) add("In your library")
else if (state.requests.containsKey(title.key)) add("Requested for you · ${state.requests.getValue(title.key).stage}")
else title.heldBy.firstOrNull()?.let { key -> add("In ${state.shell.people.firstOrNull { it.key == key }?.name ?: key}’s library") }
}.joinToString(" · ")
internal fun captionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): List<CaptionFact> = 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"))
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)) }
}

View file

@ -27,12 +27,15 @@ class DiscoverState(
var slide by mutableStateOf(0)
var title by mutableStateOf<DiscoverTitle?>(null)
var service by mutableStateOf<DiscoverService?>(null)
var collection by mutableStateOf<DiscoverCollectionState?>(null)
var titleButton by mutableStateOf(0)
private var activeOverlay by mutableStateOf<DiscoverOverlayState?>(null)
var overlay: DiscoverOverlayState?
get() = activeOverlay
set(value) { activeOverlay = value; shell.contentOverlayVisible = value != null }
var toast by mutableStateOf<String?>(null)
val observedRatings = mutableSetOf<String>()
var plateKey by mutableStateOf<String?>(null)
val requests = mutableStateMapOf<String, DiscoverRequestResult>()
private val hidden = mutableStateMapOf<String, DiscoverTitle>().apply { initial.hidden.forEach { put(it.key, it) } }
private var armedCard: String? = null
@ -85,7 +88,12 @@ class DiscoverState(
title = item; titleButton = 0; armedCard = null
onPush("discover-title/${item.key}")
}
fun openCollection(lane: DiscoverLane) {
collection = DiscoverCollectionState(shell, lane, ::openTitle, ::refresh) { collection = null; onPop() }
onPush("discover-see-all/${lane.id}")
}
fun isHidden(item: DiscoverTitle): Boolean = item.key in hidden
fun isPick(item: DiscoverTitle): Boolean = snapshot.picks.any { it.key == item.key } || isHidden(item)
fun openService(item: DiscoverService) {
rootFilter = filter; service = item; filter = "movie"; revision++
@ -94,8 +102,8 @@ class DiscoverState(
}
fun enterRoute(route: String) {
if (route == "discover") { title = null; if (service != null) filter = rootFilter; service = null }
else if (route.startsWith("discover-service")) title = null
if (route == "discover") { title = null; collection = null; if (service != null) filter = rootFilter; service = null }
else if (route.startsWith("discover-service") || route.startsWith("discover-see-all")) title = null
}
/** The shell consumes repeat events before this handler; rows activate only on key-up. */
@ -105,6 +113,7 @@ class DiscoverState(
return true
}
if (shell.route != "discover" && !shell.route.startsWith("discover-")) return false
if (title == null) collection?.let { return it.key(key, down) }
if (isCard && key == ShellKey.Ok) {
val identity = "$rowKey:$column"
if (down) armedCard = identity else {
@ -152,7 +161,7 @@ class DiscoverState(
val item = lane.titles.getOrNull(column)
if (item == null) {
if (lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)) refresh()
else { onPush("discover-see-all/${lane.id}") }
else openCollection(lane)
} else if (lane.hidden) overlay = DiscoverOverlayState.Restore(item)
else openTitle(item)
}
@ -161,9 +170,9 @@ class DiscoverState(
when (key) {
ShellKey.Up -> shell.focusNav(shell.currentTab)
ShellKey.Left -> titleButton = (titleButton - 1).coerceAtLeast(0)
ShellKey.Right -> titleButton = (titleButton + 1).coerceAtMost(2)
ShellKey.Right -> titleButton = (titleButton + 1).coerceAtMost(if (title?.let(::isPick) == true) 2 else 1)
ShellKey.Ok -> when (titleButton) {
0 -> if (requests.containsKey(requireNotNull(title).key)) shell.requestsVisible = true else openRequest(false)
0 -> if (requests[requireNotNull(title).key]?.requester == shell.person.key) shell.requestsVisible = true else openRequest(false)
1 -> openRequest(true)
2 -> title?.let { if (it.key in hidden) restore(it) else hide(it) }
}
@ -178,7 +187,13 @@ class DiscoverState(
private fun overlayKey(key: ShellKey) {
when (val sheet = overlay) {
is DiscoverOverlayState.Request -> sheet.state.key(key, ::send, { overlay = null }, ::refresh)
is DiscoverOverlayState.Request -> sheet.state.key(key, ::send, { overlay = null }) {
scope.launch {
snapshot = operations.refresh(); revision++
sheet.state.phase = if (snapshot.status == DiscoverStatus.Ready) RequestPhase.Choose else RequestPhase.RecipientsUnavailable
sheet.state.focus = sheet.state.buttonIndex
}
}
is DiscoverOverlayState.Hidden -> when (key) {
ShellKey.Left, ShellKey.Up -> sheet.focus = 0
ShellKey.Right, ShellKey.Down -> sheet.focus = 1
@ -200,7 +215,7 @@ class DiscoverState(
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)
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
}
@ -238,17 +253,20 @@ class DiscoverState(
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)
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) }
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 items = lane.titles.filter { matches(it) && it.key !in seen && it.key !in hidden }
if (items.isNotEmpty() || lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)) result += lane.copy(titles = items)
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 = hidden.values.filter(::matches)
val hiddenItems = (snapshot.hidden + snapshot.picks).distinctBy { it.key }.filter { it.key in hidden && matches(it) }
if (hiddenItems.isNotEmpty()) result += DiscoverLane("hidden", "Hidden picks", hiddenItems, hidden = true)
return result
}

View file

@ -35,12 +35,12 @@ 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]
DiscoverButton(if (receipt == null) "Request" else "Requested · ${receipt.stage}", focused && state.titleButton == 0) {
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)
}
DiscoverButton(if (state.shell.person.key in title.heldBy) "Add for someone" else "For someone else", focused && state.titleButton == 1, primary = false) { state.openRequest(true) }
DiscoverButton(if (state.isHidden(title)) "Show again" else "Not interested", focused && state.titleButton == 2, primary = false) { state.titleButton = 2; state.key(ShellKey.Ok, true) }
DiscoverButton(if (state.shell.person.key in title.heldBy) "Add for someone" else "For someone else", focused && state.titleButton == 1, glyph = "profile", primary = false) { state.openRequest(true) }
if (state.isPick(title)) DiscoverButton(if (state.isHidden(title)) "Show again" else "Not interested", focused && state.titleButton == 2, glyph = if (state.isHidden(title)) "undo" else "hide", primary = false) { state.titleButton = 2; state.key(ShellKey.Ok, true) }
}
}
}

View file

@ -20,6 +20,13 @@ fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
"seerr" -> R.drawable.wolfskin_sparkle
"advanced" -> R.drawable.wolfskin_sort
"alert" -> R.drawable.wolfskin_alert
"plus" -> R.drawable.wolfskin_plus
"check" -> R.drawable.wolfskin_check
"clock" -> R.drawable.wolfskin_clock
"hide" -> R.drawable.wolfskin_hide
"undo" -> R.drawable.wolfskin_undo
"left" -> R.drawable.wolfskin_left
"down" -> R.drawable.wolfskin_down
else -> R.drawable.wolfskin_right
}
Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color)

View file

@ -321,5 +321,12 @@ object WolfskinTokens {
val noteTop = 7.0.dp
val headerTop = 75.0.dp
val headerGap = 12.0.dp
val captionFactsGap = 9.0.dp
const val captionSeparatorOpacity = 0.4f
val badgeRing = 1.5.dp
val badgeIcon = 14.0.dp
val entryPaddingX = 11.0.dp
val entryPaddingY = 10.0.dp
val entryText = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800), lineHeight = 19.5.sp, letterSpacing = -0.01.em)
}
}

View file

@ -0,0 +1,4 @@
<!-- SPDX-License-Identifier: GPL-2.0-only -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M5 12.5l4.5 4.5L19 7.5" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View file

@ -0,0 +1,5 @@
<!-- SPDX-License-Identifier: GPL-2.0-only -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M 3.5,12.0 a 8.5,8.5 0 1,0 17.0,0 a 8.5,8.5 0 1,0 -17.0,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" />
<path android:pathData="M12 7.5V12l3 2" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" android:strokeLineCap="round" />
</vector>

View file

@ -0,0 +1,4 @@
<!-- SPDX-License-Identifier: GPL-2.0-only -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="m6 9 6 6 6-6" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View file

@ -0,0 +1,4 @@
<!-- SPDX-License-Identifier: GPL-2.0-only -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M3 3l18 18M10.6 5.1A10.6 10.6 0 0 1 12 5c6.4 0 10 7 10 7a17 17 0 0 1-3.2 4M6.6 6.6C3.8 8.4 2 12 2 12s3.6 7 10 7c1.6 0 3-.4 4.3-1" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" />
</vector>

View file

@ -0,0 +1,4 @@
<!-- SPDX-License-Identifier: GPL-2.0-only -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="m15 6-6 6 6 6" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View file

@ -0,0 +1,4 @@
<!-- SPDX-License-Identifier: GPL-2.0-only -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12 5v14M5 12h14" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" />
</vector>

View file

@ -0,0 +1,5 @@
<!-- SPDX-License-Identifier: GPL-2.0-only -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M9 7 4.5 11.5 9 16" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M5 11.5h9a5.5 5.5 0 0 1 0 11h-2" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" />
</vector>

View file

@ -40,9 +40,9 @@ class DiscoverCaptureTest {
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
}
private fun capture(name: String, person: String = "justin", configure: (DiscoverState) -> Unit = {}) {
private fun capture(name: String, person: String = "justin", snapshotTransform: (DiscoverSnapshot) -> DiscoverSnapshot = { it }, configure: (DiscoverState) -> Unit = {}) {
val shell = ShellState(fixturePeople(), person).apply { pickerVisible = false; enterDestination("discover") }
val snapshot = fixture.snapshot(person)
val snapshot = snapshotTransform(fixture.snapshot(person))
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, person), CoroutineScope(Dispatchers.Main.immediate),
onPush = shell::enterDestination, onPop = { shell.enterDestination("discover") }, serviceLanes = fixture::service)
configure(state)
@ -61,6 +61,58 @@ class DiscoverCaptureTest {
}
@Test fun st029_discover() = capture("st-029")
@Test fun st030_picksCollection() = capture("st-030") { state -> state.openCollection(state.lanes.first { it.id == "pk-movie" }) }
@Test fun st031_hidden() = capture("st-031") { hidden(it) }
@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.focus(state.lanes.indexOf(lane) + 2, 1)
}
@Test fun st034_retryTail() = capture("st-034", snapshotTransform = { snapshot ->
snapshot.copy(lanes = snapshot.lanes.map { if (it.id == "trending") it.copy(tail = DiscoverTail.Retry) else it })
}) { state -> val lane = state.lanes.first { it.id == "trending" }; state.focus(state.lanes.indexOf(lane) + 2, lane.titles.size) }
@Test fun st035_duplicateTail() = capture("st-035") { state ->
val titles = state.snapshot.lanes.first { it.id == "trending" }.fullTitles.take(36)
state.openCollection(DiscoverLane("trending", "Trending", titles, tail = DiscoverTail.ShowMore))
state.collection!!.focus(6, 0)
}
@Test fun st036_needsSignIn() = capture("st-036", snapshotTransform = { DiscoverSnapshot(status = DiscoverStatus.NeedsSignIn) })
@Test fun st037_series() = capture("st-037", person = "mindel") { it.filter = "tv" }
@Test fun st038_noSeeds() = capture("st-038", snapshotTransform = { it.copy(status = DiscoverStatus.NoSeeds) })
@Test fun st039_trendingCollection() = capture("st-039") { state -> state.openCollection(state.lanes.first { it.id == "trending" }) }
@Test fun st040_service() = capture("st-040") { state -> state.openService(state.snapshot.services.first()) }
@Test fun st041_unreachable() = capture("st-041", snapshotTransform = { DiscoverSnapshot(status = DiscoverStatus.Unavailable) })
@Test fun st058_hiddenReceipt() = capture("st-058") { state -> state.openTitle(state.snapshot.picks.first()); state.titleButton = 2; state.hide(state.title!!) }
@Test fun st059_hiddenTitle() = capture("st-059") { state -> state.hide(state.snapshot.picks.first()); state.overlay = null; state.openTitle(state.snapshot.picks.first()); state.titleButton = 2 }
@Test fun st071_requestSelf() = capture("st-071") { request(it) }
@Test fun st072_requestOther() = capture("st-072") { request(it, other = true) }
@Test fun st073_addHeld() = capture("st-073") { state -> request(state, fixture.find("The Girl Who Leapt Through Time"), other = true) }
@Test fun st074_receipt() = capture("st-074") { request(it, other = true, phase = RequestPhase.Receipt) }
@Test fun st075_notConfirmed() = capture("st-075") { request(it, phase = RequestPhase.NotConfirmed) }
@Test fun st076_pending() = capture("st-076") { request(it, phase = RequestPhase.Pending) }
@Test fun st077_refused() = capture("st-077", person = "mindel") { request(it, other = true, phase = RequestPhase.Refused) }
@Test fun st078_recipientsLoading() = capture("st-078") { request(it, other = true, phase = RequestPhase.LoadingRecipients) }
@Test fun st079_recipientsUnavailable() = capture("st-079") { request(it, other = true, phase = RequestPhase.RecipientsUnavailable) }
@Test fun st080_seasonsUnknown() = capture("st-080") { state -> request(state, state.snapshot.picks.first { it.type == "tv" }) }
@Test fun st087_sharedReceipt() = capture("st-087", person = "shared") { request(it, phase = RequestPhase.Receipt) }
@Test fun st105_pickPlate() = capture("st-105") { state -> val lane = state.lanes.first { it.id == "pk-movie" }; state.focus(state.lanes.indexOf(lane) + 2, 1) }
@Test fun st110_heldRatings() = capture("st-110") { state -> request(state, fixture.find("The Girl Who Leapt Through Time"), other = true) }
private fun hidden(state: DiscoverState) {
state.hide(state.snapshot.picks[7]); state.hide(state.snapshot.picks[20]); state.overlay = null
val lane = state.lanes.first { it.hidden }
state.focus(state.lanes.indexOf(lane) + 2, lane.titles.indexOfFirst { it.key == state.snapshot.picks[7].key })
}
private fun request(state: DiscoverState, title: DiscoverTitle = state.snapshot.picks.first(), other: Boolean = false, phase: RequestPhase = RequestPhase.Choose) {
state.openTitle(title); state.openRequest(other, phase = phase)
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
}
}
}
internal fun discoverTestFixture(): DiscoverFixture {

View file

@ -7,6 +7,7 @@ import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import kotlinx.coroutines.CoroutineScope
@ -137,4 +138,46 @@ class DiscoverKeysTest {
press(Key.DirectionCenter)
assertNotNull(state.title)
}
@Test fun downClampsToOccupiedCardsAndThenTheTail() {
shell.pickerVisible = false
val items = snapshot.picks.take(8)
state.openCollection(DiscoverLane("short", "Picks", items, tail = DiscoverTail.ShowMore))
launch()
repeat(5) { press(Key.DirectionRight) }
assertEquals(5, state.collection!!.column)
press(Key.DirectionDown)
assertEquals(1, state.collection!!.row)
assertEquals(1, state.collection!!.column)
assertEquals(items.last().key, state.collection!!.items[state.collection!!.index].key)
press(Key.DirectionDown, Key.DirectionRight)
assertEquals(2, state.collection!!.row)
assertEquals(0, state.collection!!.column)
assertTrue(state.collection!!.hasTail)
}
@Test fun collectionBackRestoresTheExactOrigin() {
shell.pickerVisible = false; shell.enterDestination("discover")
state.focus(3, 2)
val origin = shell.view
state.openCollection(state.lanes.first { it.id == "pk-movie" })
launch()
press(Key.DirectionDown, Key.Back)
assertNull(state.collection)
assertSame(origin, shell.view)
assertEquals(3, shell.view.row)
assertEquals(2, shell.view.cards[state.rowKey])
}
@Test
@Config(qualifiers = "nl-rNL-w960dp-h540dp-xhdpi")
fun dutchPlateUsesLocaleAndStaysOnOneLine() {
shell.pickerVisible = false
val title = snapshot.picks.first().copy(ratings = listOf(DiscoverRating("tmdb", 7.8)))
state.openCollection(DiscoverLane("locale", "Picks", listOf(title)))
launch()
rule.mainClock.advanceTimeBy(1000)
rule.onNodeWithText("7,8").assertExists()
assertEquals("7.8", formatDiscoverRating(title.ratings.single(), java.util.Locale.ENGLISH))
assertEquals("91%", formatDiscoverRating(DiscoverRating("rtCritics", 91.7), java.util.Locale.forLanguageTag("nl-NL")))
}
}

View file

@ -213,4 +213,10 @@
--discover-noteTop: 14.0px;
--discover-headerTop: 150.0px;
--discover-headerGap: 24.0px;
--discover-captionFactsGap: 18.0px;
--discover-badgeRing: 3.0px;
--discover-badgeIcon: 28.0px;
--discover-entryPaddingX: 22.0px;
--discover-entryPaddingY: 20.0px;
--discover-entryText: 30.0px;
}

View file

@ -1,6 +1,6 @@
d98e9ff63dc1010e7af362b36ff3dce7f4052d7de7da405c1ed6c06191a6205f discover-tokens.json
145606da685f1d54ce0225a880220676dac34470eba45b3eda1a92000bb4937d discover-tokens.json
fcff646d59fb39e859c34c05037e05a7fb7583de2fe07315ec0176cf4d597980 generate_tokens.py
6d1d19656b40e6a442cb548bb238bb12244adb6b3b717ed9e564af61119559c8 shell-tokens.json
54e91e53f3c6e928aac6f4419db2bdde458957b57e2271aef45c6d521ad6162e src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
fb3018e251c2dd795e0ed522a331da307ab6477a3fd45b246d1cb7fd514dc069 tokens.css
dfde5d83a088d19f017a16ef21a9591f4721e33769ee3fadc5591421f507bbcd src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
b284e80a0ad867aebd06412be5feb4262bcefa1945b507ec6e7e8f19a4365cf8 tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json