Build the Discover catalogue and request flow

This commit is contained in:
justinzeus 2026-11-05 16:18:19 +01:00
parent 9be55c570a
commit 6a02887dc9
664 changed files with 2103 additions and 8 deletions

View file

@ -0,0 +1,75 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.allowRgb565
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
/** Native capture supplies local painters so screenshot tests never perform image HTTP reads. */
val LocalDiscoverPainter = compositionLocalOf<((String) -> Painter?)?> { null }
/** Decode requests use the actual art slot, with RGB565 only for upright posters. */
@Composable
fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false, dim: Boolean = false) {
if (path == null) return
val painter = LocalDiscoverPainter.current?.invoke(path)
val filter = remember(dim) { if (dim) ColorFilter.colorMatrix(ColorMatrix().apply { setToScale(.55f, .55f, .55f, 1f) }) else null }
val context = LocalContext.current
val request = remember(context, path, poster) {
ImageRequest.Builder(context).data(path).size(if (poster) 256 else 1280, if (poster) 384 else 720).allowRgb565(poster).build()
}
if (painter != null) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
else AsyncImage(
model = request,
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = filter,
)
}
/** Poster-only titles retain the one-sheet over a blurred wash rather than stretching the poster. */
@Composable
fun DiscoverBackdrop(title: DiscoverTitle, detail: Boolean = false, modifier: Modifier = Modifier) {
Box(modifier.size(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(C.surface3)) {
if (title.posterOnly) {
DiscoverArtwork(title.poster, Modifier.fillMaxSize().blur(L.rowHeadingBand), dim = true)
DiscoverArtwork(title.poster, Modifier.offset(
WolfskinTokens.Shell.stageWidth - D.heroSheetRight - if (detail) L.heroSheetWidth else D.heroSheetWidth,
if (detail) L.navBand else D.heroSheetTop,
).size(if (detail) L.heroSheetWidth else D.heroSheetWidth, if (detail) L.heroSheetHeight else D.heroSheetHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet)), poster = true)
} else DiscoverArtwork(title.backdrop ?: title.poster, Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().drawWithCache {
val scrim = Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f), endX = size.width)
onDrawBehind { drawRect(scrim) }
})
Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0f to C.bg.copy(alpha = 0f), .52f to C.bg.copy(alpha = 0f), .74f to C.bg.copy(alpha = .6f), 1f to C.bg)))
}
}
/** Missing art is deterministic and quiet; it does not imply a provider read succeeded. */
fun discoverFallback(title: String): Brush {
val hue = title.fold(0) { hash, char -> (hash * 31 + char.code) % 360 }.toFloat()
return Brush.linearGradient(listOf(Color.hsl(hue, .22f, .22f), Color.hsl(hue, .26f, .10f)))
}

View file

@ -0,0 +1,78 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import org.json.JSONArray
import org.json.JSONObject
/** Projects supplied prototype data; this class never loads a bundled fixture or contacts a server. */
class DiscoverFixture(json: String, private val artPath: (String) -> String = { it }) {
private val profiles = JSONObject(json).getJSONArray("profiles").objects()
private val holdings = profiles.associate { profile ->
profile.getString("key") to listOf("movies", "shows", "anime").flatMap { profile.getJSONArray(it).objects() }
.map { identity(it) }.toSet()
}
private val pool = profiles.flatMap { it.getJSONArray("discover").objects() }.distinctBy(::identity).map(::title)
val services = listOf("Netflix", "Prime Video", "Disney+", "HBO Max", "Apple TV+", "Videoland", "Hulu", "NPO Start", "Crunchyroll")
.mapIndexed { index, name -> DiscoverService(index.toLong(), name, listOf(355f, 200f, 230f, 265f, 0f, 20f, 140f, 25f, 30f)[index]) }
fun snapshot(person: String): DiscoverSnapshot {
val profile = profiles.first { it.getString("key") == person }
val picks = profile.getJSONArray("discover").objects().map(::title)
val played = listOf("movies", "shows", "anime").flatMap { profile.getJSONArray(it).objects() }
.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 }
.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)) +
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)
}
return DiscoverSnapshot(picks, groups.values.map { DiscoverSeed(it.first, it.second.toList()) }, services, lanes, status = DiscoverStatus.Ready)
}
fun service(service: DiscoverService, type: String): List<DiscoverLane> {
val base = rotate(pool.filter { it.type == type }, service.id.toInt() * 5)
val noun = if (type == "movie") "movies" else "series"
val genres = base.flatMap { it.genres }.groupingBy { it }.eachCount().entries.sortedByDescending { it.value }.take(3)
return listOf(
DiscoverLane("sv-pop", "Popular $noun", base.drop(1).take(20)),
DiscoverLane("sv-new", "New $noun", base.sortedByDescending { it.year }.take(20)),
DiscoverLane("sv-top", "Top rated $noun", rotate(base, 11).take(20)),
) + genres.map { genre -> DiscoverLane("sv-g-${genre.key}", "${genre.key} $noun", base.filter { genre.key in it.genres }.take(20)) }
}
fun find(name: String): DiscoverTitle = (pool + profiles.flatMap { p -> listOf("movies", "shows", "anime").flatMap { p.getJSONArray(it).objects() }.map(::title) }).first { it.title == name }
private fun title(item: JSONObject): DiscoverTitle = DiscoverTitle(
tmdb = item.optLong("tmdbId", item.getString("id").hashCode().toLong().let { if (it < 0) -it else it }),
type = item.getString("type"), title = item.getString("title"), year = item.nullableInt("year"),
genres = item.getJSONArray("genres").strings(), overview = item.optString("overview"),
poster = item.nullableString("poster")?.let(artPath), backdrop = item.nullableString("backdrop")?.let(artPath),
posterOnly = item.optJSONObject("artwork")?.optString("backdropKind") == "poster_crop",
runtimeSeconds = item.nullableInt("runtimeSeconds"), seasonCount = item.nullableInt("seasonCount"),
ratings = ratings(item.optJSONObject("ratings")), heldBy = holdings.filterValues { identity(item) in it }.keys,
)
private fun ratings(json: JSONObject?): List<DiscoverRating> = buildList {
if (json == null) return@buildList
for ((field, source, label) in listOf(Triple("rtCritics", "rtCritics", "rtCriticsLabel"), Triple("rtAudience", "rtAudience", "rtAudienceLabel"), Triple("imdb", "imdb", ""))) {
if (!json.isNull(field)) add(DiscoverRating(source, json.getDouble(field), json.nullableString(label)))
}
if (isEmpty() && !json.isNull("tmdb") && json.optInt("tmdbVotes") > 0) add(DiscoverRating("tmdb", json.getDouble("tmdb")))
}
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 JSONArray.objects(): List<JSONObject> = List(length()) { getJSONObject(it) }
private fun JSONArray.strings(): List<String> = List(length()) { getString(it) }
private fun JSONObject.nullableString(key: String): String? = if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() }
private fun JSONObject.nullableInt(key: String): Int? = if (isNull(key)) null else getInt(key)

View file

@ -0,0 +1,79 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
/** Catalogue identity is stable across lanes, filters, requests and dismissals. */
data class DiscoverTitle(
val tmdb: Long,
val type: String,
val title: String,
val year: Int? = null,
val genres: List<String> = emptyList(),
val overview: String = "",
val poster: String? = null,
val backdrop: String? = null,
val posterOnly: Boolean = false,
val runtimeSeconds: Int? = null,
val seasonCount: Int? = null,
val seasons: List<Int>? = null,
val ratings: List<DiscoverRating> = emptyList(),
val heldBy: Set<String> = emptySet(),
val score: Double = 0.0,
) {
val key: String get() = "$type:$tmdb"
val typeWord: String get() = if (type == "movie") "Movie" else "Series"
}
/** Observed source marks keep labels separate from score values. */
data class DiscoverRating(val source: String, val score: Double, val label: String? = null)
/** A contribution group preserves feed order rather than assigning a visible rank. */
data class DiscoverSeed(val title: String, val items: List<DiscoverTitle>)
/** Services carry their catalogue id and a text wordmark, never third-party logos. */
data class DiscoverService(val id: Long, val name: String, val hue: Float)
enum class DiscoverStatus { Ready, NeedsSignIn, Unavailable, Loading, NoSeeds, PicksUnavailable }
enum class DiscoverTail { None, SeeAll, Retry, ShowMore }
/** Provider data is immutable; screen focus never mutates source ordering. */
data class DiscoverSnapshot(
val picks: List<DiscoverTitle> = emptyList(),
val seeds: List<DiscoverSeed> = emptyList(),
val services: List<DiscoverService> = emptyList(),
val lanes: List<DiscoverLane> = emptyList(),
val hidden: List<DiscoverTitle> = emptyList(),
val status: DiscoverStatus = DiscoverStatus.NeedsSignIn,
val savedAt: String? = null,
)
/** Lane identity survives lazy recycling and provider refreshes. */
data class DiscoverLane(
val id: String,
val title: String,
val titles: List<DiscoverTitle>,
val total: Int = titles.size,
val tail: DiscoverTail = if (total > titles.size) DiscoverTail.SeeAll else DiscoverTail.None,
val pick: Boolean = false,
val hidden: Boolean = false,
)
/** Caller identity is deliberately absent: the authenticated client supplies the actor. */
data class DiscoverRequest(val title: DiscoverTitle, val requester: String, val profile: String)
/** Request outcomes distinguish uncertainty from an acknowledged receipt. */
data class DiscoverRequestResult(
val phase: RequestPhase,
val actor: String? = null,
val stage: String = "Waiting",
)
enum class RequestPhase { Choose, LoadingRecipients, RecipientsUnavailable, Sending, Receipt, NotConfirmed, Pending, Refused }
/** Production adapters use the generated Conductarr client; fixture adapters cannot send HTTP. */
interface DiscoverOperations {
suspend fun request(intent: DiscoverRequest): DiscoverRequestResult
suspend fun check(intent: DiscoverRequest): DiscoverRequestResult
suspend fun hide(profile: String, title: DiscoverTitle): Boolean
suspend fun restore(profile: String, title: DiscoverTitle): Boolean
suspend fun refresh(): DiscoverSnapshot
}

View file

@ -0,0 +1,150 @@
// 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.Row
import androidx.compose.foundation.layout.Spacer
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.tv.material3.Text
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
/** Sheets draw after the shell nav: their scrim consumes pointers and focus stays inside. */
@Composable
fun DiscoverOverlay(state: DiscoverState) {
val overlay = state.overlay
if (overlay != null && !state.shell.pickerVisible) {
Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable(onClick = {}).testTag("discover-overlay")) {
Column(Modifier.align(Alignment.CenterEnd).width(L.sheetWidth).fillMaxSize().background(C.surface).padding(D.sheetPaddingX, D.sheetPaddingY), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) {
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) }
DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok, true) }
}
}
is DiscoverOverlayState.Restore -> {
Text(overlay.title.title, color = C.fg, style = WolfskinTokens.Type.sheetTitle)
Text("Hidden from ${state.shell.person.name}’s picks.", color = C.muted, style = WolfskinTokens.Type.body)
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title) }
DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null }
}
}
}
}
}
}
state.toast?.let { text ->
Box(Modifier.offset(WolfskinTokens.Shell.stageWidth - L.safeSide - D.captionWidth, L.navBand).width(D.captionWidth).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(L.buttonGap)) {
Text(text, color = C.fg, style = WolfskinTokens.Type.body)
}
}
}
@Composable
private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
Text(if (sheet.held) "Add for someone" else "Request", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
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.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)
}
}
@Composable
private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) {
Text("For", color = C.dim, style = WolfskinTokens.Type.label)
sheet.people.forEachIndexed { index, person ->
val focused = sheet.focus == index
val disabled = person.key in sheet.title.heldBy
Row(Modifier.fillMaxWidth().height(L.sheetRow).alpha(if (disabled) .5f else 1f)
.focusScale(focused, WolfskinTokens.Focus.scaleRow)
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
.clickable(enabled = !disabled) { sheet.selected = index; sheet.focus = sheet.buttonIndex }
.padding(horizontal = D.sheetRowPadding), horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) {
Avatar(person, D.sheetAvatar, WolfskinTokens.Type.avatarLetterSheetRow)
Text(person.name + if (person.key == sheet.viewer.key) " (you)" else "", Modifier.weight(1f), color = if (focused) C.onFocus else C.fg, style = WolfskinTokens.Type.listTitle)
if (disabled) Text("In library", color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption)
else Box(Modifier.size(D.sheetRadio).background(if (sheet.selected == index) C.accent else C.listRowRest.copy(alpha = 0f), CircleShape)
.border(if (sheet.selected == index) WolfskinTokens.Shell.zero else WolfskinTokens.Focus.ringCardRing, if (focused) C.onFocus.copy(alpha = .5f) else C.fg.copy(alpha = .35f), CircleShape), contentAlignment = Alignment.Center) {
if (sheet.selected == index) Box(Modifier.size(D.sheetRadioDot).background(C.onFocus, CircleShape))
}
}
}
if (sheet.blocked) Text("Season details are unknown right now, so this series can’t be requested yet. Nothing was sent.", color = C.warn, style = WolfskinTokens.Type.caption)
else if (sheet.title.type == "tv" && !sheet.held) Text("Whole series: every listed season. Other seasons may download too.", color = C.dim, style = WolfskinTokens.Type.caption)
Spacer(Modifier.height(D.sheetButtonTop))
DiscoverButton(if (sheet.blocked) "Close" else sheet.buttonLabel, sheet.focus == sheet.buttonIndex) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok, true) }
}
@Composable
private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) {
val phase = sheet.phase
val name = sheet.selectedPerson.name
val actor = sheet.result?.actor ?: state.shell.person.name
val text = when (phase) {
RequestPhase.Receipt -> "for ${if (sheet.selectedPerson.key == state.shell.person.key) "you" else name} · by $actor"
RequestPhase.NotConfirmed -> "The request service didn’t answer. Nothing is assumed filed."
RequestPhase.Pending -> "Seerr hasn’t confirmed it yet. Checking again looks first, so nothing is filed twice."
RequestPhase.Refused -> "$name can’t receive requests from this TV."
else -> "Conductarr didn’t say who this TV may request for. Nothing was sent."
}
val heading = when (phase) {
RequestPhase.Receipt -> if (sheet.held) "Adding to $name’s library" else "Requested"
RequestPhase.NotConfirmed -> "Not confirmed"
RequestPhase.Pending -> "Request unresolved"
RequestPhase.Refused -> "Refused"
else -> "Can’t read who can receive requests"
}
ReceiptPanel(heading, text, if (phase == RequestPhase.Receipt) C.fg else if (phase == RequestPhase.Refused) C.fail else C.warn,
if (phase == RequestPhase.Receipt) sheet.result?.stage ?: "Waiting" else null)
if (phase == RequestPhase.Receipt) Text("Follow it under Requests in Settings.", color = C.dim, style = WolfskinTokens.Type.caption)
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
if (phase in setOf(RequestPhase.NotConfirmed, RequestPhase.Pending, RequestPhase.RecipientsUnavailable)) {
DiscoverButton(if (phase == RequestPhase.RecipientsUnavailable) "Try again" else "Check again", sheet.focus == sheet.buttonIndex) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok, true) }
DiscoverButton("Close", sheet.focus != sheet.buttonIndex, primary = false) { state.overlay = null }
} else DiscoverButton(if (phase == RequestPhase.Receipt) "Done" else "Close", true) { state.overlay = null }
}
}
@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)) {
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)
}
}

View file

@ -0,0 +1,73 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.tv.material3.Text
import java.util.Locale
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
/** Localized score formatting never infers a tomato label from its percentage. */
fun formatDiscoverRating(rating: DiscoverRating, locale: Locale): String =
if (rating.source.startsWith("rt")) "${rating.score.toInt()}%" else String.format(locale, "%.1f", rating.score)
/** Full rows retain every observed score; plates shrink then remove only trailing entries. */
@Composable
fun DiscoverRatings(ratings: List<DiscoverRating>, modifier: Modifier = Modifier, plate: Boolean = false) {
if (ratings.isEmpty()) return
val locale = LocalConfiguration.current.locales[0]
val labels = remember(ratings, locale) { ratings.map { formatDiscoverRating(it, locale) } }
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
BoxWithConstraints(modifier) {
val available = with(density) { maxWidth.toPx() }
val base = WolfskinTokens.Type.ratingPlate
var font = base.fontSize.value
var count = ratings.size
fun width(): Float {
val style = base.copy(fontSize = TextUnit(font, TextUnitType.Sp))
return (0 until count).sumOf { index ->
val mark = if (ratings[index].source in setOf("imdb", "tmdb")) 1.8f else 1f
(measurer.measure(labels[index], style).size.width + with(density) { style.fontSize.toPx() } * (mark + if (plate) .29f else .2857143f)).toDouble()
}.toFloat() + (count - 1) * with(density) { style.fontSize.toPx() } * (if (plate) .64f else .7142857f)
}
if (plate) {
while (font > WolfskinTokens.Type.caption.fontSize.value && width() > available) font = (font - .25f).coerceAtLeast(WolfskinTokens.Type.caption.fontSize.value)
while (count > 1 && width() > available) count--
}
val style = base.copy(fontSize = TextUnit(font, TextUnitType.Sp), fontFeatureSettings = "tnum")
val markSize = with(density) { style.fontSize.toDp() }
Row(horizontalArrangement = Arrangement.spacedBy(markSize * if (plate) .64f else .7142857f)) {
repeat(count) { index ->
val rating = ratings[index]
val resource = when (rating.source) {
"rtCritics" -> if (rating.label == "Rotten") R.drawable.ic_rotten_tomatoes_rotten else R.drawable.ic_rotten_tomatoes_fresh
"rtAudience" -> if (rating.label == "Spilled") R.drawable.ic_rt_spilled else R.drawable.ic_rt_upright
"imdb" -> R.drawable.ic_rating_imdb
else -> R.drawable.ic_rating_tmdb
}
Row(horizontalArrangement = Arrangement.spacedBy(markSize * if (plate) .29f else .2857143f)) {
val muted = rating.source == "rtCritics" && rating.label !in setOf("Rotten", "Fresh", "Certified Fresh")
Image(painterResource(resource), null, Modifier.height(markSize).width(markSize * if (rating.source in setOf("imdb", "tmdb")) 1.8f else 1f), alpha = if (muted) .55f else 1f,
colorFilter = if (muted) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null)
Text(labels[index], color = C.fg, style = style, maxLines = 1)
}
}
}
}
}

View file

@ -0,0 +1,156 @@
// 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.Spacer
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
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.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.Text
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
/** Discover uses the shell's real scroller, Back ladder and per-person focus memory. */
@Composable
fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
val shell = state.shell
DisposableEffect(state) {
shell.contentInput = state::key
onDispose { 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 } }
if (state.title != null) { DiscoverTitleDestination(state, modifier); return }
val density = LocalDensity.current
val list = shell.view.listState
LaunchedEffect(state.focusedRow, shell.navFocused) {
if (!shell.navFocused) {
val row = state.focusedRow
val alreadyFits = state.service != null && row <= 2
if (row <= 1 || alreadyFits) list.animateScrollToItem(0)
else list.animateScrollToItem(row, -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 "")
}
item(key = "discover-chips", contentType = "chips") {
if (state.snapshot.status !in setOf(DiscoverStatus.NeedsSignIn, DiscoverStatus.Unavailable, DiscoverStatus.Loading)) DiscoverChips(state)
}
if (state.snapshot.status in setOf(DiscoverStatus.NeedsSignIn, DiscoverStatus.Unavailable)) item(key = "discover-state", contentType = "state") { DiscoverStatePanel(state) }
else {
if (state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) item(key = "discover-note", contentType = "note") {
Text(if (state.snapshot.status == DiscoverStatus.NoSeeds) "Watch something to get picks for ${shell.person.name}." else "Picks unavailable right now.", Modifier.height(L.row_note).padding(start = L.safeSide, top = D.noteTop), color = C.muted, style = WolfskinTokens.Type.body)
}
itemsIndexed(state.lanes, key = { _, lane -> lane.id }, contentType = { _, _ -> "shelf" }) { index, lane -> DiscoverShelf(state, lane, index + state.firstShelf) }
}
}
}
}
/** Header and type chips use the same safe area as the shell. */
@Composable
private fun DiscoverHeader(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) {
Text(title, color = C.fg, style = WolfskinTokens.Type.screenTitle)
if (subtitle.isNotEmpty()) Text(subtitle, color = C.muted, style = WolfskinTokens.Type.nav)
}
}
@Composable
private fun DiscoverHero(state: DiscoverState) {
val title = state.hero ?: return
Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) {
Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) {
Glyph("seerr", C.accentText, Modifier.size(L.buttonIcon))
Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = WolfskinTokens.Type.label)
}
Spacer(Modifier.height(D.heroKickerGap))
Text(title.title, Modifier.width(L.heroCopyWidth), color = C.fg, style = WolfskinTokens.Type.displayHero, maxLines = 2, overflow = TextOverflow.Ellipsis)
Spacer(Modifier.height(D.heroTitleGap))
Row(Modifier.height(D.heroFactsHeight), horizontalArrangement = Arrangement.spacedBy(D.heroFactsGap), verticalAlignment = Alignment.CenterVertically) {
Text("${title.typeWord}${title.year?.let { " · $it" }.orEmpty()}", color = C.muted, style = WolfskinTokens.Type.body)
if (title.genres.isNotEmpty()) Text(title.genres.take(2).joinToString(", "), color = C.muted, style = WolfskinTokens.Type.body)
if (state.isSignedIn) DiscoverRatings(title.ratings)
if (state.shell.person.key in title.heldBy) Text("In your library", color = C.accentText, style = WolfskinTokens.Type.body)
}
Spacer(Modifier.height(D.heroMetaGap))
Text(title.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = WolfskinTokens.Type.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
Spacer(Modifier.height(D.heroOverviewGap))
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)
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)
}
}
}
}
@Composable
private fun DiscoverChips(state: DiscoverState) {
Row(Modifier.fillMaxWidth().height(L.row_chips).padding(start = L.safeSide, top = D.chipTop), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
(if (state.service == null) listOf("all" to "All", "movie" to "Movies", "tv" to "Series") else listOf("movie" to "Movies", "tv" to "Series")).forEachIndexed { index, (value, label) ->
val focused = state.contentFocused && state.focusedRow == 1 && state.column == index
Box(Modifier.height(D.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.background(if (focused) C.fg else if (state.filter == value) C.accentSoft else C.chipRest, RoundedCornerShape(WolfskinTokens.Radius.chip))
.border(if (!focused && state.filter == value) WolfskinTokens.Focus.ringCardRing / 2 else WolfskinTokens.Shell.zero, if (!focused && state.filter == value) C.accent else C.chipRest.copy(alpha = 0f), RoundedCornerShape(WolfskinTokens.Radius.chip))
.clickable { state.focus(1, index); state.key(ShellKey.Ok, true) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) {
Text(label, color = if (focused) C.onFocus else if (state.filter == value) C.accentText else C.fg, style = WolfskinTokens.Type.label)
}
}
}
}
@Composable
private fun DiscoverStatePanel(state: DiscoverState) {
val signIn = state.snapshot.status == DiscoverStatus.NeedsSignIn
Column(Modifier.padding(start = L.safeSide, top = D.stateTop).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(D.statePaddingX, D.statePaddingY)) {
Row(horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
Glyph(if (signIn) "profile" else "alert", if (signIn) C.accentText else C.warn, Modifier.size(L.buttonIcon))
Text(if (signIn) "Discover needs sign-in" else "Discover is resting", color = C.fg, style = WolfskinTokens.Type.stateTitle)
}
Text(if (signIn) "Sign this TV in to Conductarr to see picks and request titles. Your library plays as usual." else "Conductarr can’t be reached. Your library still plays.", Modifier.padding(top = L.navBlobAvatarPad, bottom = D.stateTextGap), color = C.muted, style = WolfskinTokens.Type.body)
DiscoverButton("Try again", state.contentFocused) { state.refresh() }
}
}
/** Hero and sheet buttons reuse the shell's scale and outside-ring primitives. */
@Composable
fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) {
Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent)
.background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.button))
.clickable(onClick = onClick).padding(horizontal = L.buttonPaddingX), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon))
Text(label, color = if (primary) C.onFocus else C.fg, style = WolfskinTokens.Type.button, maxLines = 1)
}
}

View file

@ -0,0 +1,138 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.tween
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.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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.zIndex
import androidx.tv.material3.Text
import kotlinx.coroutines.delay
import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
/** Every shelf is lazy and keyed; the focused caption is outside the scaled art slot. */
@Composable
fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
val services = lane.id == "svc"
val height = if (services) K.entryH else K.posterH
val cards = rememberLazyListState()
val column = state.shell.view.cards[lane.id] ?: 0
val active = state.contentFocused && state.focusedRow == row
LaunchedEffect(column) { cards.animateScrollToItem((column - 5).coerceAtLeast(0)) }
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) {
Text(lane.title, Modifier.offset(L.safeSide), color = C.fg, style = WolfskinTokens.Type.rowHeading, maxLines = 1)
LazyRow(state = cards, contentPadding = PaddingValues(horizontal = L.safeSide, vertical = K.posterGap), horizontalArrangement = Arrangement.spacedBy(K.posterGap),
modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) {
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
.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)
}
} else {
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->
DiscoverPoster(state, item, active && index == column, lane.hidden, Modifier.clickable {
state.focus(row, index)
if (lane.hidden) state.overlay = DiscoverOverlayState.Restore(item) else state.openTitle(item)
})
}
if (lane.tail != DiscoverTail.None) item(key = "tail:${lane.id}", contentType = if (lane.tail == DiscoverTail.SeeAll) "seeAll" else "tail") {
DiscoverTailCard(lane, active && column == lane.titles.size) { state.focus(row, lane.titles.size); state.key(ShellKey.Ok, true); state.key(ShellKey.Ok, false) }
}
}
}
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)
}
}
}
}
/** 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 }
}
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)) {
Box(Modifier.fillMaxSize().clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(discoverFallback(title.title))) {
Text(title.title, Modifier.padding(L.buttonGap), color = C.fg, style = WolfskinTokens.Type.button, maxLines = 4)
DiscoverArtwork(title.poster, Modifier.fillMaxSize(), poster = true)
if (plate && state.isSignedIn && title.ratings.isNotEmpty()) {
Box(Modifier.fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = .9f), C.bg.copy(alpha = .72f), C.bg.copy(alpha = 0f)))).padding(start = D.plateSide, end = D.plateSide, top = D.plateTop, bottom = D.plateFade)) {
DiscoverRatings(title.ratings, Modifier.fillMaxWidth(), plate = true)
}
}
val result = state.requests[title.key]
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)
}
}
}
}
}
@Composable
private fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: () -> Unit) {
Column(Modifier.size(K.posterW, K.posterH).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)).border(WolfskinTokens.Focus.ringCardRing / 2, C.border, RoundedCornerShape(WolfskinTokens.Radius.card)).clickable(onClick = onClick).padding(L.buttonGap),
verticalArrangement = Arrangement.spacedBy(L.buttonGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph(if (lane.tail == DiscoverTail.SeeAll) "interface" else "alert", if (lane.tail == DiscoverTail.Retry) C.warn else C.accent, Modifier.size(L.badge))
Text(when (lane.tail) { DiscoverTail.SeeAll -> "See all"; DiscoverTail.Retry -> "Try again"; else -> "Show more" }, color = C.fg, style = WolfskinTokens.Type.listTitle)
Text(when (lane.tail) { DiscoverTail.SeeAll -> lane.total.toString(); DiscoverTail.Retry -> "More titles couldn’t load"; else -> "Only titles already shown came back" }, color = C.muted, style = WolfskinTokens.Type.caption)
}
}
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(" · ")

View file

@ -0,0 +1,257 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.math.ceil
/** One person owns a Discover view; the shell retains its root list and card memory. */
class DiscoverState(
val shell: ShellState,
initial: DiscoverSnapshot,
private val operations: DiscoverOperations,
private val scope: CoroutineScope,
private val onPush: (String) -> Unit = {},
private val onPop: () -> Unit = {},
private val serviceLanes: (DiscoverService, String) -> List<DiscoverLane> = { _, _ -> emptyList() },
) {
var snapshot by mutableStateOf(initial)
private set
var filter by mutableStateOf("all")
var slide by mutableStateOf(0)
var title by mutableStateOf<DiscoverTitle?>(null)
var service by mutableStateOf<DiscoverService?>(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 requests = mutableStateMapOf<String, DiscoverRequestResult>()
private val hidden = mutableStateMapOf<String, DiscoverTitle>().apply { initial.hidden.forEach { put(it.key, it) } }
private var armedCard: String? = null
private var revision by mutableStateOf(0)
private var cachedRevision = -1
private var cachedFilter = ""
private var cachedService: DiscoverService? = null
private var cachedLanes = emptyList<DiscoverLane>()
private var cachedHeroesRevision = -1
private var cachedHeroesFilter = ""
private var cachedHeroes = emptyList<DiscoverTitle>()
private val dismissalMutex = Mutex()
private var dismissalVersion = 0
private var rootFilter = "all"
val isSignedIn: Boolean get() = snapshot.status != DiscoverStatus.NeedsSignIn
val hasHero: Boolean get() = heroes.isNotEmpty() && snapshot.status !in setOf(DiscoverStatus.NeedsSignIn, DiscoverStatus.Unavailable, DiscoverStatus.Loading)
val heroes: List<DiscoverTitle> get() {
if (cachedHeroesRevision != revision || cachedHeroesFilter != filter) {
val source = if (snapshot.status == DiscoverStatus.Ready) snapshot.picks else snapshot.lanes.firstOrNull { it.id == "trending" }?.titles.orEmpty()
cachedHeroes = source.asSequence().filter { it.key !in hidden && matches(it) }
.sortedWith(compareByDescending<DiscoverTitle> { it.score }.thenBy { if (it.type == "movie") 0 else 1 })
.take(if (snapshot.status == DiscoverStatus.Ready) 5 else 1).toList()
cachedHeroesRevision = revision; cachedHeroesFilter = filter
}
return cachedHeroes
}
val hero: DiscoverTitle? get() = heroes.getOrNull(slide.coerceAtMost((heroes.size - 1).coerceAtLeast(0)))
val lanes: List<DiscoverLane> get() {
if (cachedRevision != revision || cachedFilter != filter || cachedService != service) {
cachedLanes = buildLanes(); cachedRevision = revision; cachedFilter = filter; cachedService = service
}
return cachedLanes
}
val firstShelf: Int get() = 2
val rowCount: Int get() = firstShelf + lanes.size
val focusedRow: Int get() = shell.view.row.coerceIn(0, (rowCount - 1).coerceAtLeast(0))
val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf)
val column: Int get() = shell.view.cards[rowKey].orZero()
val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() }
val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null
val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null
fun focus(row: Int, column: Int = 0) {
shell.navFocused = false; shell.view.row = row
shell.view.cards[rowKey] = column
}
fun openTitle(item: DiscoverTitle) {
title = item; titleButton = 0; armedCard = null
onPush("discover-title/${item.key}")
}
fun isHidden(item: DiscoverTitle): Boolean = item.key in hidden
fun openService(item: DiscoverService) {
rootFilter = filter; service = item; filter = "movie"; revision++
onPush("discover-service/${item.id}")
focus(firstShelf)
}
fun enterRoute(route: String) {
if (route == "discover") { title = null; if (service != null) filter = rootFilter; service = null }
else if (route.startsWith("discover-service")) title = null
}
/** The shell consumes repeat events before this handler; rows activate only on key-up. */
fun key(key: ShellKey, down: Boolean): Boolean {
if (overlay != null) {
if (down) overlayKey(key)
return true
}
if (shell.route != "discover" && !shell.route.startsWith("discover-")) return false
if (isCard && key == ShellKey.Ok) {
val identity = "$rowKey:$column"
if (down) armedCard = identity else {
if (armedCard == identity) activateCard()
armedCard = null
}
return true
}
if (!down) return true
armedCard = null
if (key == ShellKey.Back) {
if (title != null) { title = null; onPop() }
else if (service != null) { service = null; filter = rootFilter; revision++; onPop() }
else shell.focusNav(shell.currentTab)
return true
}
if (title != null) { titleKey(key); return true }
when (key) {
ShellKey.Up -> if (focusedRow <= if (hasHero) 0 else 1) shell.focusNav(shell.currentTab) else focus(focusedRow - 1, shell.view.cards[if (focusedRow == 2) "dchips" else lanes.getOrNull(focusedRow - 3)?.id].orZero())
ShellKey.Down -> focus((focusedRow + 1).coerceAtMost(rowCount - 1))
ShellKey.Left, ShellKey.Right -> {
val direction = if (key == ShellKey.Left) -1 else 1
if (focusedRow == 0) slide = (slide + direction).coerceIn(0, (heroes.size - 1).coerceAtLeast(0))
else shell.view.cards[rowKey] = (column + direction).coerceIn(0, maxColumn())
}
ShellKey.Ok -> when {
!isSignedIn || snapshot.status == DiscoverStatus.Unavailable -> refresh()
focusedRow == 0 -> hero?.let(::openTitle)
focusedRow == 1 -> { filter = (if (service == null) listOf("all", "movie", "tv") else listOf("movie", "tv"))[column]; slide = 0; revision++ }
}
else -> Unit
}
return true
}
private fun maxColumn(): Int = when (focusedRow) {
0 -> heroes.lastIndex
1 -> if (service == null) 2 else 1
else -> if (focusedLane?.id == "svc") snapshot.services.lastIndex else focusedLane?.let { it.titles.size - 1 + if (it.tail != DiscoverTail.None) 1 else 0 } ?: 0
}.coerceAtLeast(0)
private fun activateCard() {
val lane = focusedLane ?: return
if (lane.id == "svc") { snapshot.services.getOrNull(column)?.let(::openService); return }
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 if (lane.hidden) overlay = DiscoverOverlayState.Restore(item)
else openTitle(item)
}
private fun titleKey(key: ShellKey) {
when (key) {
ShellKey.Up -> shell.focusNav(shell.currentTab)
ShellKey.Left -> titleButton = (titleButton - 1).coerceAtLeast(0)
ShellKey.Right -> titleButton = (titleButton + 1).coerceAtMost(2)
ShellKey.Ok -> when (titleButton) {
0 -> if (requests.containsKey(requireNotNull(title).key)) shell.requestsVisible = true else openRequest(false)
1 -> openRequest(true)
2 -> title?.let { if (it.key in hidden) restore(it) else hide(it) }
}
else -> Unit
}
}
fun openRequest(other: Boolean, item: DiscoverTitle = requireNotNull(title), phase: RequestPhase = RequestPhase.Choose) {
if (!isSignedIn || snapshot.status == DiscoverStatus.Unavailable) { refresh(); return }
overlay = DiscoverOverlayState.Request(RequestSheetState(item, shell.person, shell.people, other, phase))
}
private fun overlayKey(key: ShellKey) {
when (val sheet = overlay) {
is DiscoverOverlayState.Request -> sheet.state.key(key, ::send, { overlay = null }, ::refresh)
is DiscoverOverlayState.Hidden -> when (key) {
ShellKey.Left, ShellKey.Up -> sheet.focus = 0
ShellKey.Right, ShellKey.Down -> sheet.focus = 1
ShellKey.Back -> overlay = null
ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title) else { overlay = null; title = null; onPop() }
}
is DiscoverOverlayState.Restore -> when (key) {
ShellKey.Left, ShellKey.Up -> sheet.focus = 0
ShellKey.Right, ShellKey.Down -> sheet.focus = 1
ShellKey.Back -> overlay = null
ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title) else overlay = null
}
null -> Unit
}
}
private fun send(sheet: RequestSheetState, check: Boolean) {
if (!isSignedIn || snapshot.status == DiscoverStatus.Unavailable || (!check && !sheet.canSubmit)) return
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)
sheet.result = result; sheet.phase = result.phase; sheet.focus = sheet.buttonIndex
if (result.phase == RequestPhase.Receipt) requests[sheet.title.key] = result
}
}
fun hide(item: DiscoverTitle) {
hidden[item.key] = item; revision++; overlay = DiscoverOverlayState.Hidden(item)
val version = ++dismissalVersion
scope.launch {
val success = dismissalMutex.withLock { operations.hide(shell.person.key, item) }
if (!success && dismissalVersion == version) { hidden.remove(item.key); revision++; overlay = null; toast = "Couldn’t hide this pick. Try again." }
}
}
fun restore(item: DiscoverTitle) {
hidden.remove(item.key); revision++; overlay = null; toast = "Back in your picks."
val version = ++dismissalVersion
scope.launch {
val success = dismissalMutex.withLock { operations.restore(shell.person.key, item) }
if (!success && dismissalVersion == version) { hidden[item.key] = item; revision++; toast = "Couldn’t restore this pick. Try again." }
}
}
fun refresh() { scope.launch { snapshot = operations.refresh(); revision++; toast = if (snapshot.status == DiscoverStatus.Unavailable) "Still can’t reach it. The last saved view stays on screen." else null } }
private fun matches(item: DiscoverTitle): Boolean = filter == "all" || item.type == filter
private fun buildLanes(): List<DiscoverLane> {
service?.let { return serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() } }
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.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)
}
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) }
}
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 hiddenItems = hidden.values.filter(::matches)
if (hiddenItems.isNotEmpty()) result += DiscoverLane("hidden", "Hidden picks", hiddenItems, hidden = true)
return result
}
}
private fun Int?.orZero(): Int = this ?: 0

View file

@ -0,0 +1,47 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.tv.material3.Text
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
/** Explicit destination stub exposes only the Discover-owned request and pick actions. */
@Composable
fun DiscoverTitleDestination(state: DiscoverState, modifier: Modifier = Modifier) {
val title = state.title ?: return
val focused = state.contentFocused
Box(modifier.fillMaxSize().testTag("discover-title-destination-stub")) {
DiscoverBackdrop(title, detail = true)
Column(Modifier.offset(L.safeSide, L.detailCopyTop).width(L.detailCopyWidth)) {
Text(title.title, color = C.fg, style = WolfskinTokens.Type.displayDetail, maxLines = 2)
Spacer(Modifier.height(D.heroTitleGap))
Text("${title.typeWord}${title.year?.let { " · $it" }.orEmpty()} · ${title.genres.take(2).joinToString(", ")}", color = C.muted, style = WolfskinTokens.Type.body)
Spacer(Modifier.height(D.heroMetaGap))
if (state.isSignedIn) DiscoverRatings(title.ratings)
Spacer(Modifier.height(D.heroMetaGap))
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) {
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) }
}
}
}
}

View file

@ -0,0 +1,66 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/** Overlay focus is independent of the screen below and never reaches the navigation bar. */
sealed interface DiscoverOverlayState {
class Request(val state: RequestSheetState) : DiscoverOverlayState
class Hidden(val title: DiscoverTitle) : DiscoverOverlayState { var focus by mutableStateOf(0) }
class Restore(val title: DiscoverTitle) : DiscoverOverlayState { var focus by mutableStateOf(0) }
}
/** Request selection cannot supply an actor and cannot submit an unknown-season series. */
class RequestSheetState(
val title: DiscoverTitle,
val viewer: ShellPerson,
people: List<ShellPerson>,
other: Boolean,
initialPhase: RequestPhase = RequestPhase.Choose,
) {
val people = listOf(viewer) + people.filter { it.key != viewer.key }
val held = viewer.key in title.heldBy
val blocked = title.type == "tv" && !held && title.seasons.isNullOrEmpty()
val buttonIndex = this.people.size
var selected by mutableStateOf(if (other || held) this.people.indexOfFirst { it.key != viewer.key && it.key !in title.heldBy }.coerceAtLeast(0) else 0)
var phase by mutableStateOf(initialPhase)
var focus by mutableStateOf(if (initialPhase != RequestPhase.Choose || blocked || !other) buttonIndex else selected)
var result by mutableStateOf<DiscoverRequestResult?>(null)
val selectedPerson: ShellPerson get() = people[selected]
val canSubmit: Boolean get() = phase == RequestPhase.Choose && !blocked && selectedPerson.key !in title.heldBy
val buttonLabel: String get() = when {
held -> "Add to ${selectedPerson.name}’s library"
selectedPerson.key == viewer.key -> "Request"
else -> "Request for ${selectedPerson.name}"
}
fun key(key: ShellKey, send: (RequestSheetState, Boolean) -> Unit, close: () -> Unit, retryRecipients: () -> Unit) {
if (key == ShellKey.Back) { close(); return }
when (phase) {
RequestPhase.Choose -> {
val order = people.indices.filter { people[it].key !in title.heldBy } + buttonIndex
val index = order.indexOf(focus)
when (key) {
ShellKey.Up -> focus = order[(index - 1).coerceAtLeast(0)]
ShellKey.Down -> focus = order[(index + 1).coerceAtMost(order.lastIndex)]
ShellKey.Ok -> if (focus == buttonIndex) { if (blocked) close() else if (canSubmit) send(this, false) }
else { selected = focus; focus = buttonIndex }
else -> Unit
}
}
RequestPhase.Sending -> Unit
RequestPhase.LoadingRecipients -> if (key == ShellKey.Ok) close()
RequestPhase.Receipt, RequestPhase.Refused -> if (key == ShellKey.Ok) close()
RequestPhase.NotConfirmed, RequestPhase.Pending, RequestPhase.RecipientsUnavailable -> {
when (key) {
ShellKey.Left, ShellKey.Up -> focus = buttonIndex
ShellKey.Right, ShellKey.Down -> focus = buttonIndex + 1
ShellKey.Ok -> if (focus != buttonIndex) close() else if (phase == RequestPhase.RecipientsUnavailable) retryRecipients() else send(this, true)
else -> Unit
}
}
}
}
}

View file

@ -50,6 +50,8 @@ class ShellState(
var requestsVisible by mutableStateOf(false)
var handOff by mutableStateOf<String?>(null)
var settingsRow by mutableStateOf(0)
var contentOverlayVisible by mutableStateOf(false)
var contentInput: ((ShellKey, Boolean) -> Boolean)? = null
private var rootRoute by mutableStateOf("home")
private var pickerReturnRoute = "home"
private val views = mutableStateMapOf<Pair<String, String>, RememberedView>()
@ -71,6 +73,9 @@ class ShellState(
/** Repeats are consumed, and a row activation is cancelled when focus moves. */
fun key(key: ShellKey, down: Boolean = true, repeat: Int = 0, card: String? = null): Boolean {
if (key == ShellKey.Ok && repeat > 0) return true
if (!pickerVisible && !requestsVisible && (contentOverlayVisible || !navFocused) &&
contentInput?.invoke(key, down) == true) return true
if (key == ShellKey.Ok) {
if (repeat > 0) return true
if (card != null) {

View file

@ -52,6 +52,7 @@ fun ShellSurface(
modifier: Modifier = Modifier,
handlesContentKeys: Boolean = true,
settingsOpenRequests: Int? = null,
overlay: @Composable () -> Unit = {},
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
) {
val focus = remember { FocusRequester() }
@ -88,6 +89,7 @@ fun ShellSurface(
}
}
}
overlay()
}
}
}

View file

@ -279,4 +279,46 @@ object WolfskinTokens {
const val navBandOpacity = 0.72f
const val navSolidStop = 0.84f
}
object Discover {
val sheetPaddingX = 32.0.dp
val sheetPaddingY = 40.0.dp
val sheetGap = 9.0.dp
val sheetSubGap = 7.0.dp
val sheetRowPadding = 13.0.dp
val sheetAvatar = 30.0.dp
val sheetRadio = 20.0.dp
val sheetRadioDot = 8.0.dp
val sheetPersonGap = 12.0.dp
val sheetButtonTop = 6.0.dp
val chipTop = 8.0.dp
val chipHeight = 34.0.dp
val chipPadding = 14.0.dp
val chipGap = 8.0.dp
val heroTitleGap = 8.0.dp
val heroKickerGap = 5.0.dp
val heroMetaGap = 7.0.dp
val heroOverviewGap = 15.0.dp
val heroFactsGap = 11.0.dp
val heroFactsHeight = 19.0.dp
val heroSheetRight = 90.0.dp
val heroSheetTop = 70.0.dp
val heroSheetWidth = 180.0.dp
val heroSheetHeight = 270.0.dp
val dotSize = 7.0.dp
val dotActive = 22.0.dp
val dotGap = 7.0.dp
val dotMargin = 9.0.dp
val dotChevron = 20.0.dp
val stateTop = 10.0.dp
val statePaddingX = 20.0.dp
val statePaddingY = 17.0.dp
val stateTextGap = 12.0.dp
val captionWidth = 320.0.dp
val plateTop = 7.0.dp
val plateSide = 8.0.dp
val plateFade = 23.0.dp
val noteTop = 7.0.dp
val headerTop = 75.0.dp
val headerGap = 12.0.dp
}
}

View file

@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="575dp"
android:height="289.83dp"
android:viewportWidth="575"
android:viewportHeight="289.83">
<path
android:pathData="M575 24.91C573.44 12.15 563.97 1.98 551.91 0C499.05 0 76.18 0 23.32 0C10.11 2.17 0 14.16 0 28.61C0 51.84 0 237.64 0 260.86C0 276.86 12.37 289.83 27.64 289.83C79.63 289.83 495.6 289.83 547.59 289.83C561.65 289.83 573.26 278.82 575 264.57C575 216.64 575 48.87 575 24.91Z"
android:fillColor="#f6c700"/>
<path
android:pathData="M69.35 58.24L114.98 58.24L114.98 233.89L69.35 233.89L69.35 58.24Z"
android:fillColor="#000000"/>
<path
android:pathData="M201.2 139.15C197.28 112.38 195.1 97.5 194.67 94.53C192.76 80.2 190.94 67.73 189.2 57.09C185.25 57.09 165.54 57.09 130.04 57.09L130.04 232.74L170.01 232.74L170.15 116.76L186.97 232.74L215.44 232.74L231.39 114.18L231.54 232.74L271.38 232.74L271.38 57.09L211.77 57.09L201.2 139.15Z"
android:fillColor="#000000"/>
<path
android:pathData="M346.71 93.63C347.21 95.87 347.47 100.95 347.47 108.89C347.47 115.7 347.47 170.18 347.47 176.99C347.47 188.68 346.71 195.84 345.2 198.48C343.68 201.12 339.64 202.43 333.09 202.43C333.09 190.9 333.09 98.66 333.09 87.13C338.06 87.13 341.45 87.66 343.25 88.7C345.05 89.75 346.21 91.39 346.71 93.63ZM367.32 230.95C372.75 229.76 377.31 227.66 381.01 224.67C384.7 221.67 387.29 217.52 388.77 212.21C390.26 206.91 391.14 196.38 391.14 180.63C391.14 174.47 391.14 125.12 391.14 118.95C391.14 102.33 390.49 91.19 389.48 85.53C388.46 79.86 385.93 74.71 381.88 70.09C377.82 65.47 371.9 62.15 364.12 60.13C356.33 58.11 343.63 57.09 321.54 57.09C319.27 57.09 307.93 57.09 287.5 57.09L287.5 232.74L342.78 232.74C355.52 232.34 363.7 231.75 367.32 230.95Z"
android:fillColor="#000000"/>
<path
android:pathData="M464.76 204.7C463.92 206.93 460.24 208.06 457.46 208.06C454.74 208.06 452.93 206.98 452.01 204.81C451.09 202.65 450.64 197.72 450.64 190C450.64 185.36 450.64 148.22 450.64 143.58C450.64 135.58 451.04 130.59 451.85 128.6C452.65 126.63 454.41 125.63 457.13 125.63C459.91 125.63 463.64 126.76 464.6 129.03C465.55 131.3 466.03 136.15 466.03 143.58C466.03 146.58 466.03 161.58 466.03 188.59C465.74 197.84 465.32 203.21 464.76 204.7ZM406.68 231.21L447.76 231.21C449.47 224.5 450.41 220.77 450.6 220.02C454.32 224.52 458.41 227.9 462.9 230.14C467.37 232.39 474.06 233.51 479.24 233.51C486.45 233.51 492.67 231.62 497.92 227.83C503.16 224.05 506.5 219.57 507.92 214.42C509.34 209.26 510.05 201.42 510.05 190.88C510.05 185.95 510.05 146.53 510.05 141.6C510.05 131 509.81 124.08 509.34 120.83C508.87 117.58 507.47 114.27 505.14 110.88C502.81 107.49 499.42 104.86 494.98 102.98C490.54 101.1 485.3 100.16 479.26 100.16C474.01 100.16 467.29 101.21 462.81 103.28C458.34 105.35 454.28 108.49 450.64 112.7C450.64 108.89 450.64 89.85 450.64 55.56L406.68 55.56L406.68 231.21Z"
android:fillColor="#000000"/>
</vector>

View file

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="185.04dp"
android:height="133.4dp"
android:viewportWidth="185.04"
android:viewportHeight="133.4">
<path android:pathData="M51.06,66.7h0A17.67,17.67,0,0,1,68.73,49h-.1A17.67,17.67,0,0,1,86.3,66.7h0A17.67,17.67,0,0,1,68.63,84.37h.1A17.67,17.67,0,0,1,51.06,66.7Zm82.67-31.33h32.9A17.67,17.67,0,0,0,184.3,17.7h0A17.67,17.67,0,0,0,166.63,0h-32.9A17.67,17.67,0,0,0,116.06,17.7h0A17.67,17.67,0,0,0,133.73,35.37Zm-113,98h63.9A17.67,17.67,0,0,0,102.3,115.7h0A17.67,17.67,0,0,0,84.63,98H20.73A17.67,17.67,0,0,0,3.06,115.7h0A17.67,17.67,0,0,0,20.73,133.37Zm83.92-49h6.25L125.5,49h-8.35l-8.9,23.2h-.1L99.4,49H90.5Zm32.45,0h7.8V49h-7.8Zm22.2,0h24.95V77.2H167.1V70h15.35V62.8H167.1V56.2h16.25V49h-24ZM10.1,35.4h7.8V6.9H28V0H0V6.9H10.1ZM39,35.4h7.8V20.1H61.9V35.4h7.8V0H61.9V13.2H46.75V0H39Zm41.25,0h25V28.2H88V21h15.35V13.8H88V7.2h16.25V0h-24Zm-79,49H9V57.25h.1l9,27.15H24l9.3-27.15h.1V84.4h7.8V49H29.45l-8.2,23.1h-.1L13,49H1.2Zm112.09,49H126a24.59,24.59,0,0,0,7.56-1.15,19.52,19.52,0,0,0,6.35-3.37,16.37,16.37,0,0,0,4.37-5.5A16.91,16.91,0,0,0,146,115.8a18.5,18.5,0,0,0-1.68-8.25,15.1,15.1,0,0,0-4.52-5.53A18.55,18.55,0,0,0,133.07,99,33.54,33.54,0,0,0,125,98H113.29Zm7.81-28.2h4.6a17.43,17.43,0,0,1,4.67.62,11.68,11.68,0,0,1,3.88,1.88,9,9,0,0,1,2.62,3.18,9.87,9.87,0,0,1,1,4.52,11.92,11.92,0,0,1-1,5.08,8.69,8.69,0,0,1-2.67,3.34,10.87,10.87,0,0,1-4,1.83,21.57,21.57,0,0,1-5,.55H121.1Zm36.14,28.2h14.5a23.11,23.11,0,0,0,4.73-.5,13.38,13.38,0,0,0,4.27-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68,9.16,9.16,0,0,0-.55-3.2,7.79,7.79,0,0,0-1.57-2.62,8.38,8.38,0,0,0-2.45-1.85,10,10,0,0,0-3.18-1v-.1a9.28,9.28,0,0,0,4.43-2.82,7.42,7.42,0,0,0,1.67-5,8.34,8.34,0,0,0-1.15-4.65,7.88,7.88,0,0,0-3-2.73,12.9,12.9,0,0,0-4.17-1.3,34.42,34.42,0,0,0-4.63-.32h-13.2Zm7.8-28.8h5.3a10.79,10.79,0,0,1,1.85.17,5.77,5.77,0,0,1,1.7.58,3.33,3.33,0,0,1,1.23,1.13,3.22,3.22,0,0,1,.47,1.82,3.63,3.63,0,0,1-.42,1.8,3.34,3.34,0,0,1-1.13,1.2,4.78,4.78,0,0,1-1.57.65,8.16,8.16,0,0,1-1.78.2H165Zm0,14.15h5.9a15.12,15.12,0,0,1,2.05.15,7.83,7.83,0,0,1,2,.55,4,4,0,0,1,1.58,1.17,3.13,3.13,0,0,1,.62,2,3.71,3.71,0,0,1-.47,1.95,4,4,0,0,1-1.23,1.3,4.78,4.78,0,0,1-1.67.7,8.91,8.91,0,0,1-1.83.2h-7Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear" android:startX="0" android:startY="66.7" android:endX="185.04" android:endY="66.7">
<item android:offset="0" android:color="#90cea1"/>
<item android:offset="0.56" android:color="#3cbec9"/>
<item android:offset="1" android:color="#00b3e5"/>
</gradient>
</aapt:attr>
</path>
</vector>

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="138.75dp"
android:height="141.25dp"
android:viewportWidth="138.75"
android:viewportHeight="141.25">
<path
android:pathData="m20.15,40.83c-28.15,27.62 -13.66,61.01 -5.73,71.93 35.25,41.95 92.79,25.34 111.89,-5.91 4.76,-8.2 22.55,-53.47 -23.98,-78.01z"
android:fillColor="#f93208"/>
<path
android:pathData="m39.61,39.26 l4.78,-8.86 28.41,-5.04 11.12,9.21z"
android:fillColor="#f93208"/>
<path
android:pathData="m39.44,8.57 l8.97,-5.28 6.76,15.48c3.79,-6.32 13.79,-16.32 24.94,-4.67 -4.73,1.26 -7.52,3.86 -7.74,8.48 15.15,-4.17 31.34,3.21 33.54,9.09 -10.95,-4.31 -27.69,10.38 -41.77,2.33 0.01,15.05 -12.62,16.64 -19.9,17.08 2.08,-5 5.59,-9.99 1.47,-14.99 -7.62,8.17 -13.87,10.67 -33.17,4.67 4.88,-1.68 14.84,-11.39 24.45,-11.43 -6.78,-2.47 -12.29,-2.09 -17.81,-1.48 2.92,-3.96 12.15,-15.2 28.63,-8.48z"
android:fillColor="#02902e"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="145dp"
android:height="140dp"
android:viewportWidth="145"
android:viewportHeight="140">
<path
android:pathData="M47.4,35.34c-13.61,-7.93 -12.32,-25.2 2.1,-31.88 26.12,-6.53 29.12,13.78 22.65,30.41 -6.54,24.11 18.09,23.66 19.92,10.07 3.61,-18.41 19.39,-26.69 31.67,-16.36 12.6,12.14 7.07,36.58 -17.83,34.19 -16.03,-1.54 -19.55,19.58 0.84,21.18 32.23,1.91 42.49,22.17 31.04,35.87 -15.99,15.15 -37.69,-4.44 -45.51,-19.5 -6.8,-9.31 -17.32,0.11 -13.42,6.5 12.98,19.47 2.92,31.23 -10.91,30.62 -13.37,-0.85 -20.96,-9.06 -13.21,-29.15 3.9,-12.48 -8.6,-15.39 -16.57,-5.45 -11.71,19.61 -28.86,13.68 -33.98,4.19 -3.24,-7.62 -2.92,-25.85 24.12,-23.7 16.69,4.14 11.78,-12.56 -0.63,-13.63 -9.24,-0.44 -30.5,-7.3 -22.86,-24.54 7.34,-11.06 24.96,-11.77 33.35,6.29 3.04,4.23 8.36,11.04 18.04,5.03 3.51,-5.2 1.21,-13.9 -8.81,-20.14z"
android:fillColor="#0fc755"/>
</vector>

View file

@ -0,0 +1,73 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="143.75dp"
android:height="108.75dp"
android:viewportWidth="143.75"
android:viewportHeight="108.75">
<path
android:pathData="m96.641 2.9657c28.149 1.101 27.459 97.814 0.825 97.194l-74.45-9.973c-25.51-7.211-25.922-69.313-0.534-76.178z"
android:fillColor="#07a23b"/>
<path
android:pathData="m85.419 8.8789-63.171 8.9751c-4.2681 3.3648-6.9679 8.2192-7.5687 9.3296l65.017-6.963c1.3226-3.3762 4.3015-9.5395 5.7202-11.342z"
android:fillColor="#fff"/>
<path
android:pathData="m78.042 28.008-65.329 5.5498c-2.494 4.757-3.169 10.65-3.3147 15.17l66.739-1.5147c0.0074-3.2891 0.55003-13.318 1.9052-19.206z"
android:fillColor="#fff"/>
<path
android:pathData="m85.595 94.456-63.251-8.403c-4.2975-3.326-7.0398-8.1557-7.6503-9.2607l65.082 6.3737c1.3522 3.3644 4.3852 9.4999 5.8189 11.29z"
android:fillColor="#fff"/>
<path
android:pathData="m78.051 75.394-65.375-4.957c-2.536-4.734-3.2627-10.621-3.4481-15.14l66.749 0.9101c0.03629 3.2889 0.66694 13.312 2.0737 19.188z"
android:fillColor="#fff"/>
<path
android:pathData="m100.36 10.836c-13.099 0.685-19.878 48.223-11.732 71.195l21.342-4.561c8.39-24.044 1.28-66.986-9.61-66.634z"
android:fillColor="#03621e"/>
<path
android:pathData="m99.087 78.942a6.1255 6.1255 0 0 1 -6.1238 6.1255 6.1255 6.1255 0 0 1 -6.1273 -6.122 6.1255 6.1255 0 0 1 6.1202 -6.1291 6.1255 6.1255 0 0 1 6.1309 6.1184"
android:fillColor="#fdd600"/>
<path
android:pathData="m112.96 75.406a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887"
android:fillColor="#fdd600"/>
<path
android:pathData="m120.39 74.743a4.9323 4.9323 0 0 1 -4.9309 4.9323 4.9323 4.9323 0 0 1 -4.9337 -4.9294 4.9323 4.9323 0 0 1 4.928 -4.9352 4.9323 4.9323 0 0 1 4.9366 4.9266"
android:fillColor="#fdd600"/>
<path
android:pathData="m124.1 78.942a3.7391 3.7391 0 0 1 -3.738 3.7391 3.7391 3.7391 0 0 1 -3.7402 -3.7369 3.7391 3.7391 0 0 1 3.7358 -3.7412 3.7391 3.7391 0 0 1 3.7423 3.7347"
android:fillColor="#fdd600"/>
<path
android:pathData="m131.08 83.14a5.0207 5.0207 0 0 1 -5.0192 5.0207 5.0207 5.0207 0 0 1 -5.0222 -5.0178 5.0207 5.0207 0 0 1 5.0163 -5.0236 5.0207 5.0207 0 0 1 5.0251 5.0148"
android:fillColor="#fdd600"/>
<path
android:pathData="m135.86 91.67a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887"
android:fillColor="#fdd600"/>
<path
android:pathData="m140.36 97.327a3.9158 3.9158 0 0 1 -3.9147 3.9158 3.9158 3.9158 0 0 1 -3.917 -3.9136 3.9158 3.9158 0 0 1 3.9124 -3.9181 3.9158 3.9158 0 0 1 3.9192 3.9113"
android:fillColor="#fdd600"/>
<path
android:pathData="m140.45 99.625a4.181 4.181 0 0 1 -4.1798 4.181 4.181 4.181 0 0 1 -4.1822 -4.1786 4.181 4.181 0 0 1 4.1773 -4.1834 4.181 4.181 0 0 1 4.1846 4.1761"
android:fillColor="#fdd600"/>
<path
android:pathData="m134.44 100.55a4.8439 4.8439 0 0 1 -4.8425 4.8439 4.8439 4.8439 0 0 1 -4.8453 -4.8411 4.8439 4.8439 0 0 1 4.8397 -4.8467 4.8439 4.8439 0 0 1 4.8481 4.8383"
android:fillColor="#fdd600"/>
<path
android:pathData="m126.84 100.24a4.0042 4.0042 0 0 1 -4.0031 4.0042 4.0042 4.0042 0 0 1 -4.0054 -4.0019 4.0042 4.0042 0 0 1 4.0007 -4.0065 4.0042 4.0042 0 0 1 4.0077 3.9996"
android:fillColor="#fdd600"/>
<path
android:pathData="m125.43 97.636a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031"
android:fillColor="#fdd600"/>
<path
android:pathData="m117.12 98.078a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031"
android:fillColor="#fdd600"/>
<path
android:pathData="m110.49 97.459a3.6065 3.6065 0 0 1 -3.6054 3.6065 3.6065 3.6065 0 0 1 -3.6075 -3.6044 3.6065 3.6065 0 0 1 3.6033 -3.6086 3.6065 3.6065 0 0 1 3.6096 3.6023"
android:fillColor="#fdd600"/>
<path
android:pathData="m105.72 96.929a4.0484 4.0484 0 0 1 -4.0472 4.0484 4.0484 4.0484 0 0 1 -4.0496 -4.0461 4.0484 4.0484 0 0 1 4.0449 -4.0508 4.0484 4.0484 0 0 1 4.052 4.0437"
android:fillColor="#fdd600"/>
<path
android:pathData="m94.71 80.271c2.1568-1.7217 5.4319-2.8842 9.5881-3.6062l11.579 0.61872 15.203 13.612-2.0329 9.6343-27.047-2.5633-4.7288 1.4584-11.183-17.899z"
android:fillColor="#fdd600"/>
<path
android:pathData="m85.913 71.627c3.2472 12.036 7.0507 22.57 12.64 28.284l-9.9879-1.591s-5.5685-25.456-4.8614-25.544c0.70711-0.08839 2.2097-1.149 2.2097-1.149z"
android:fillColor="#09a339"/>
</vector>

View file

@ -0,0 +1,52 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="106.25dp"
android:height="140dp"
android:viewportWidth="106.25"
android:viewportHeight="140">
<path
android:pathData="M2.727 39.537c-.471-21.981 100.88-25.089 100.88-.42L92.91 117.56c-7.605 26.86-72.064 27.007-79.07.21z"
android:fillColor="#fa3106"/>
<path
android:pathData="M8.809 51.911l9.018 66.639c3.472 4.515 8.498 7.384 9.648 8.022l-6.921-68.576c-3.498-1.41-9.881-4.579-11.745-6.083zM28.629 59.776l5.453 68.898c4.926 2.652 11.04 3.391 15.73 3.566l-1.258-70.366c-3.414-.024-13.82-.642-19.925-2.098zM97.632 52.121l-9.019 66.643c-3.472 4.515-8.498 7.384-9.647 8.022l6.92-68.583c3.5-1.41 9.882-4.579 11.746-6.082zM77.812 59.986l-5.453 68.898c-4.926 2.652-11.04 3.391-15.73 3.566l1.258-70.366c3.414-.024 13.82-.642 19.925-2.098z"
android:fillColor="#fff"/>
<path
android:pathData="M6.397,31.252a6.816,6.816 0 1,0 13.632,0a6.816,6.816 0 1,0 -13.632,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M15.415,27.687a6.607,6.607 0 1,0 13.214,0a6.607,6.607 0 1,0 -13.214,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M24.434,19.769a5.925,5.925 0 1,0 11.85,0a5.925,5.925 0 1,0 -11.85,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M28.943,15.155a6.03,6.03 0 1,0 12.06,0a6.03,6.03 0 1,0 -12.06,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M40.164,17.095a4.929,4.929 0 1,0 9.858,0a4.929,4.929 0 1,0 -9.858,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M44.883,9.597a6.24,6.24 0 1,0 12.48,0a6.24,6.24 0 1,0 -12.48,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M54.636,9.387a6.554,6.554 0 1,0 13.108,0a6.554,6.554 0 1,0 -13.108,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M63.025,13.635a4.929,4.929 0 1,0 9.858,0a4.929,4.929 0 1,0 -9.858,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M70.156,17.672a5.925,5.925 0 1,0 11.85,0a5.925,5.925 0 1,0 -11.85,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M74.561,22.706a4.352,4.352 0 1,0 8.704,0a4.352,4.352 0 1,0 -8.704,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M78.232,26.324a5.243,5.243 0 1,0 10.486,0a5.243,5.243 0 1,0 -10.486,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M82.426,34.398a5.768,5.768 0 1,0 11.536,0a5.768,5.768 0 1,0 -11.536,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M87.355 35.447c5.79 2.799 1.352-2.213 10.696 2.097-9.574 15.338-74.774 16.892-90.291.525l-.21-3.985L38.59 16.99l22.863-6.606 15.52 9.962z"
android:fillColor="#ffd600"/>
</vector>

View file

@ -0,0 +1,83 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.BitmapFactory
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Captures execute the shipped Compose surface with offline fixture artwork and actual shell focus. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverCaptureTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val fixture = discoverTestFixture()
@Before fun fullscreen() {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
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 = {}) {
val shell = ShellState(fixturePeople(), person).apply { pickerVisible = false; enterDestination("discover") }
val snapshot = 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)
val painters = mutableMapOf<String, BitmapPainter>()
rule.setContent {
WolfskinTheme {
CompositionLocalProvider(LocalDiscoverPainter provides { path -> painters.getOrPut(path) { BitmapPainter(BitmapFactory.decodeFile(path).asImageBitmap()) } }) {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), overlay = { DiscoverOverlay(state) }) { DiscoverScreen(state, it) }
}
}
}
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val directory = File(System.getProperty("wolfskin.captureDirectory")).apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
}
@Test fun st029_discover() = capture("st-029")
}
internal fun discoverTestFixture(): DiscoverFixture {
val json = DiscoverCaptureTest::class.java.getResourceAsStream("/wolfskin-discover.json")!!.bufferedReader().use { it.readText() }
val assets = File("../app/src/debug/assets/wolfskin-discover").canonicalFile
return DiscoverFixture(json) { File(assets, it).absolutePath }
}
/** In-process effects are inspectable without exposing any live client or endpoint. */
internal class RecordingDiscoverOperations(private val snapshot: DiscoverSnapshot, private val actor: String) : DiscoverOperations {
val sent = mutableListOf<DiscoverRequest>()
var checked = 0
var next = RequestPhase.Receipt
var mutationSucceeds = true
override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult { sent += intent; return DiscoverRequestResult(next, actor.replaceFirstChar { it.uppercase() }) }
override suspend fun check(intent: DiscoverRequest): DiscoverRequestResult { checked++; return DiscoverRequestResult(next, actor.replaceFirstChar { it.uppercase() }) }
override suspend fun hide(profile: String, title: DiscoverTitle) = mutationSucceeds
override suspend fun restore(profile: String, title: DiscoverTitle) = mutationSucceeds
override suspend fun refresh() = snapshot
}

View file

@ -0,0 +1,140 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import android.app.Application
import android.view.KeyEvent as AndroidKeyEvent
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.performKeyInput
import androidx.compose.ui.test.pressKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Acceptance data drives real Android events through the shell's production focus adapter. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class DiscoverKeysTest {
@get:Rule val rule = createComposeRule()
private val fixture = discoverTestFixture()
private val shell = ShellState(fixturePeople(), "justin")
private val snapshot = fixture.snapshot("justin")
private val operations = RecordingDiscoverOperations(snapshot, "justin")
private val state = DiscoverState(shell, snapshot, operations, CoroutineScope(Dispatchers.Main.immediate), shell::enterDestination,
{ shell.enterDestination("discover") }, fixture::service)
private fun launch() {
rule.setContent {
WolfskinTheme {
ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier ->
if (shell.route == "discover" || shell.route.startsWith("discover-")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier)
}
}
}
}
private fun press(first: Key, second: Key? = null) = listOfNotNull(first, second).forEach { key -> rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } }
private fun replay(goal: String) {
val paths = JSONObject(javaClass.getResourceAsStream("/discover-keys.json")!!.bufferedReader().use { it.readText() }).getJSONArray("pressPaths")
val path = (0 until paths.length()).map { paths.getJSONObject(it) }.single { it.getString("goal") == goal }
val keys = path.getJSONArray("keys")
assertEquals(path.getInt("presses"), keys.length())
launch()
repeat(keys.length()) { index -> press(when (keys.getString(index)) {
"OK" -> Key.DirectionCenter; "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown; "LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight; else -> Key.Back
}) }
rule.waitForIdle()
}
@Test fun openDiscoverInFourPresses() {
replay("Open Discover")
assertEquals("discover", shell.route)
assertFalse(shell.navFocused)
assertEquals("The Amazing Spider-Man", state.hero?.title)
}
@Test fun requestForYourselfInSevenPresses() {
replay("Request a pick for yourself")
val sheet = (state.overlay as DiscoverOverlayState.Request).state
assertEquals(RequestPhase.Receipt, sheet.phase)
assertEquals("justin", operations.sent.single().requester)
assertEquals("Justin", sheet.result?.actor)
assertEquals("Waiting", state.requests.getValue(requireNotNull(state.title).key).stage)
}
@Test fun requestForMindelInNinePresses() {
replay("Request a pick for Mindel")
val sheet = (state.overlay as DiscoverOverlayState.Request).state
assertEquals(RequestPhase.Receipt, sheet.phase)
assertEquals("mindel", operations.sent.single().requester)
assertEquals("Justin", sheet.result?.actor)
}
@Test fun notInterestedInEightPressesAndUndoRestoresEveryIndex() {
val original = state.lanes.map { lane -> lane.id to lane.titles.map { it.key } }
replay("Round 3 · Not interested on a pick (Discover hero)")
val hidden = state.overlay as DiscoverOverlayState.Hidden
assertTrue(state.isHidden(hidden.title))
assertTrue(state.heroes.none { it.key == hidden.title.key })
assertTrue(state.lanes.filterNot { it.hidden }.none { lane -> lane.titles.any { it.key == hidden.title.key } })
press(Key.DirectionCenter)
assertNull(state.overlay)
assertEquals("The Amazing Spider-Man", state.hero?.title)
assertEquals(original, state.lanes.map { lane -> lane.id to lane.titles.map { it.key } })
}
@Test fun heldOkOpensSheetButNeverSubmits() {
replay("Open Discover")
press(Key.DirectionCenter)
rule.runOnIdle {
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
repeat(8) { index -> shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, index + 1))) }
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
}
assertEquals(RequestPhase.Choose, (state.overlay as DiscoverOverlayState.Request).state.phase)
assertTrue(operations.sent.isEmpty())
}
@Test fun uncertainOutcomeChecksWithoutResending() {
operations.next = RequestPhase.NotConfirmed
replay("Request a pick for yourself")
assertEquals(1, operations.sent.size)
assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase)
operations.next = RequestPhase.Receipt
press(Key.DirectionCenter)
assertEquals(1, operations.sent.size)
assertEquals(1, operations.checked)
assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase)
}
@Test fun unknownSeriesSeasonsNeverSubmitAndBackRestoresOpener() {
replay("Open Discover")
rule.runOnIdle { state.openTitle(snapshot.picks.first { it.type == "tv" }); state.openRequest(false) }
press(Key.DirectionCenter)
assertTrue(operations.sent.isEmpty())
assertNull(state.overlay)
assertNotNull(state.title)
assertFalse(shell.navFocused)
}
@Test fun cardOkWaitsForReleaseAndFocusMoveCancelsActivation() {
replay("Open Discover")
press(Key.DirectionDown, Key.DirectionDown)
rule.runOnIdle { shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) }
assertNull(state.title)
press(Key.DirectionRight)
rule.runOnIdle { shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) }
assertNull(state.title)
press(Key.DirectionCenter)
assertNotNull(state.title)
}
}

View file

@ -0,0 +1,92 @@
{
"pressPaths": [
{
"goal": "Open Discover",
"keys": [
"OK",
"UP",
"RIGHT",
"OK"
],
"presses": 4,
"note": "",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
},
{
"goal": "Request a pick for yourself",
"keys": [
"OK",
"UP",
"RIGHT",
"OK",
"OK",
"OK",
"OK"
],
"presses": 7,
"note": "",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
},
{
"goal": "Request a pick for Mindel",
"keys": [
"OK",
"UP",
"RIGHT",
"OK",
"OK",
"RIGHT",
"OK",
"OK",
"OK"
],
"presses": 9,
"note": "",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
},
{
"goal": "Round 3 · Not interested on a pick (Discover hero)",
"keys": [
"OK",
"UP",
"RIGHT",
"OK",
"OK",
"RIGHT",
"RIGHT",
"OK"
],
"presses": 8,
"note": "",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
},
{
"goal": "Round 4 · Discover pick nobody holds → its Trailer (sample hand-off)",
"keys": [
"OK",
"UP",
"RIGHT",
"OK",
"OK",
"RIGHT",
"RIGHT",
"RIGHT",
"OK"
],
"presses": 9,
"note": "4 on the title page (3 Rights to Trailer, OK); The Amazing Spider-Man",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05",
"requires": "Conductarr trailer field (spec 3.2, fifth addition); the press path runs once it lands"
}
]
}

File diff suppressed because one or more lines are too long