Build the Discover catalogue and request flow
This commit is contained in:
parent
9be55c570a
commit
6a02887dc9
664 changed files with 2103 additions and 8 deletions
|
|
@ -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)))
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(" · ")
|
||||
|
|
@ -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
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
wolfskin-design/src/main/res/drawable/ic_rating_imdb.xml
Normal file
22
wolfskin-design/src/main/res/drawable/ic_rating_imdb.xml
Normal 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>
|
||||
16
wolfskin-design/src/main/res/drawable/ic_rating_tmdb.xml
Normal file
16
wolfskin-design/src/main/res/drawable/ic_rating_tmdb.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
73
wolfskin-design/src/main/res/drawable/ic_rt_spilled.xml
Normal file
73
wolfskin-design/src/main/res/drawable/ic_rt_spilled.xml
Normal 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>
|
||||
52
wolfskin-design/src/main/res/drawable/ic_rt_upright.xml
Normal file
52
wolfskin-design/src/main/res/drawable/ic_rt_upright.xml
Normal 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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
92
wolfskin-design/src/test/resources/discover-keys.json
Normal file
92
wolfskin-design/src/test/resources/discover-keys.json
Normal 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
Loading…
Add table
Add a link
Reference in a new issue