Build household requests under Settings
|
|
@ -0,0 +1,23 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Debug assets and native screenshots consume the same projection of the frozen prototype. */
|
||||
fun readComingInFixture(json: String, poster: (String) -> Any?): List<ComingInRequest> {
|
||||
val items = JSONObject(json).getJSONArray("items")
|
||||
return List(items.length()) { index ->
|
||||
val item = items.getJSONObject(index)
|
||||
val scores = item.getJSONArray("ratings")
|
||||
ComingInRequest(
|
||||
id = item.getString("id"), mediaType = item.getString("mediaType"), tmdbId = item.getLong("tmdbId"),
|
||||
title = item.getString("title"), requesterKey = item.getString("requesterKey"), requesterName = item.getString("requesterName"),
|
||||
actorKey = item.getString("actorKey"), actorName = item.getString("actorName"), stage = ComingInStage.valueOf(item.getString("stage")),
|
||||
poster = poster(item.getString("poster")),
|
||||
ratings = List(scores.length()) { scoreIndex ->
|
||||
val score = scores.getJSONObject(scoreIndex)
|
||||
ComingInRating(score.getString("mark"), score.getDouble("score"), score.getBoolean("percentage"))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// 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.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.tv.material3.Text
|
||||
import java.util.Locale
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
|
||||
|
||||
/** Percentages remain whole; ten-point scores use the television's locale. */
|
||||
fun formatComingInRating(rating: ComingInRating, locale: Locale = Locale.getDefault()): String =
|
||||
if (rating.percentage) "${rating.score.toInt()}%" else String.format(locale, "%.1f", rating.score)
|
||||
|
||||
/** This row always displays the full supplier set rather than the card plate's shortened prefix. */
|
||||
@Composable
|
||||
fun ComingInRatings(ratings: List<ComingInRating>, color: Color) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.ratingsGap)) {
|
||||
ratings.forEach { rating ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.ratingsEntryGap)) {
|
||||
val mark = when (rating.mark) {
|
||||
"fresh" -> R.drawable.coming_in_rating_fresh
|
||||
"rotten" -> R.drawable.coming_in_rating_rotten
|
||||
"upright" -> R.drawable.coming_in_rating_upright
|
||||
"spilled" -> R.drawable.coming_in_rating_spilled
|
||||
"imdb" -> R.drawable.coming_in_rating_imdb
|
||||
else -> R.drawable.coming_in_rating_tmdb
|
||||
}
|
||||
val wide = rating.mark == "imdb" || rating.mark == "tmdb"
|
||||
Image(painterResource(mark), contentDescription = rating.mark,
|
||||
modifier = Modifier.height(T.readinessGlyph).width(T.readinessGlyph * if (wide) T.ratingsWordmarkRatio else 1f))
|
||||
Text(formatComingInRating(rating), style = ComingInTypography.rating, color = color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
|
||||
|
||||
/** The overlay intercepts pointer input as well as the shell's virtual D-pad focus. */
|
||||
@Composable
|
||||
fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
|
||||
val state = shell.requests
|
||||
val data = state.snapshot
|
||||
LaunchedEffect(state.focusedId, data.signedIn, data.status) {
|
||||
state.awaitFocusedRatings()
|
||||
}
|
||||
Box(modifier.fillMaxSize().background(C.sheetScrim).clickable { shell.closeRequests() }.testTag("coming-in-scrim"))
|
||||
Column(modifier.fillMaxHeight().width(L.sheetWidth)
|
||||
.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth)
|
||||
.drawWithCache {
|
||||
val panel = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = C.surface.toArgb()
|
||||
setShadowLayer(T.shadowBlur.toPx(), T.shadowOffset.toPx(), 0f, T.shadowColor.toArgb())
|
||||
}
|
||||
onDrawBehind {
|
||||
drawIntoCanvas { it.nativeCanvas.drawRect(0f, 0f, size.width, size.height, panel) }
|
||||
}
|
||||
}.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) awaitPointerEvent(PointerEventPass.Final).changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
.padding(horizontal = T.sheetPaddingX, vertical = T.sheetPaddingY)
|
||||
.testTag("coming-in"), verticalArrangement = Arrangement.spacedBy(T.sheetGap)) {
|
||||
Text("Coming in", style = ComingInTypography.title, color = C.fg)
|
||||
when {
|
||||
data.status == ComingInStatus.Loading && data.items.isEmpty() -> {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(T.listGap)) {
|
||||
items(3, key = { "request-skeleton-$it" }, contentType = { "skeleton" }) { ComingInSkeleton() }
|
||||
}
|
||||
SheetButton("Close") { shell.closeRequests() }
|
||||
}
|
||||
data.status == ComingInStatus.Unavailable && data.items.isEmpty() -> {
|
||||
Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
.padding(horizontal = T.receiptPaddingX, vertical = T.receiptPaddingY),
|
||||
verticalArrangement = Arrangement.spacedBy(T.receiptGap)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowPaddingY)) {
|
||||
Glyph("alert", C.warn, Modifier.size(T.receiptGlyph))
|
||||
Text("Requests unavailable", style = ComingInTypography.stateTitle, color = C.warn)
|
||||
}
|
||||
Text("Conductarr didn’t answer.", style = ComingInTypography.body, color = C.muted)
|
||||
}
|
||||
SheetButton("Try again", primary = true) { shell.retryRequests() }
|
||||
}
|
||||
data.items.isEmpty() -> Text("Nothing coming in.", Modifier.padding(top = T.emptyMargin), style = ComingInTypography.body, color = C.muted)
|
||||
else -> {
|
||||
if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn)
|
||||
val start = state.windowStart
|
||||
val visibleRows = remember(data, start, state.windowSize) {
|
||||
data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size))
|
||||
}
|
||||
LazyColumn(Modifier.clipToBounds(), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
|
||||
itemsIndexed(visibleRows,
|
||||
key = { _, item -> item.id }, contentType = { _, _ -> "request" }) { _, item ->
|
||||
ComingInRow(item, shell.person, item.id == state.focusedId, state.expanded && item.id == state.focusedId,
|
||||
state.ratingsVisible && data.signedIn && item.id == state.focusedId,
|
||||
onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetButton(label: String, primary: Boolean = false, onClick: () -> Unit) {
|
||||
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, true, primary, onClick) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ComingInSkeleton() {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY),
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) {
|
||||
Box(Modifier.size(L.thumbWidth, L.thumbHeight).background(C.surface3, RoundedCornerShape(WolfskinTokens.Radius.thumb)))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(T.skeletonGap)) {
|
||||
Box(Modifier.size(T.skeletonTitleWidth, T.skeletonTitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Shell.skeletonRadius)))
|
||||
Box(Modifier.size(T.skeletonSubtitleWidth, T.skeletonSubtitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Thumbnail slots do not resize when the text or the readiness block grows. */
|
||||
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, expanded: Boolean,
|
||||
showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit) {
|
||||
val foreground = if (focused) C.onFocus else C.fg
|
||||
val secondary = if (focused) C.onFocus else C.muted
|
||||
Row(Modifier.fillMaxWidth().focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
||||
.background(if (focused) C.fg else Color.Transparent, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
.pointerInput(item.id) {
|
||||
awaitPointerEventScope {
|
||||
while (true) if (awaitPointerEvent().type == PointerEventType.Enter) onFocus()
|
||||
}
|
||||
}.clickable(onClick = onClick)
|
||||
.padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY).testTag("coming-row-${item.id}"),
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) {
|
||||
AsyncImage(model = item.poster, contentDescription = null,
|
||||
modifier = Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb)).background(C.surface3),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(item.title, style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Row {
|
||||
Text("for ${item.recipient(viewer)}", style = ComingInTypography.caption, color = secondary)
|
||||
Text(" · ", style = ComingInTypography.caption, color = secondary.copy(alpha = secondary.alpha * T.separatorAlpha))
|
||||
Text("by ${item.actor(viewer)}", style = ComingInTypography.caption, color = secondary)
|
||||
}
|
||||
FlowRow(Modifier.padding(top = T.stageMargin), horizontalArrangement = Arrangement.spacedBy(T.readinessGlyph)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.stageGap)) {
|
||||
Glyph(item.stage.glyph, secondary, Modifier.size(T.stageGlyph))
|
||||
Text(item.stage.label, style = ComingInTypography.label, color = secondary)
|
||||
}
|
||||
if (showRatings) ComingInRatings(item.ratings, foreground)
|
||||
}
|
||||
if (expanded) FlowRow(Modifier.padding(top = T.readinessMargin),
|
||||
horizontalArrangement = Arrangement.spacedBy(T.readinessGapX), verticalArrangement = Arrangement.spacedBy(T.readinessGapY)) {
|
||||
item.stage.readiness.forEachIndexed { index, word ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.stageGap)) {
|
||||
val glyph = when (word) { "Done" -> "check"; "Failed" -> "alert"; "Not yet", "Unknown" -> null; else -> "clock" }
|
||||
if (glyph != null) Glyph(glyph, secondary, Modifier.size(T.readinessGlyph))
|
||||
Text("${READINESS_NAMES[index]} · $word", style = ComingInTypography.readiness, color = secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (focused && !expanded) Glyph("right", foreground.copy(alpha = T.chevronAlpha), Modifier.size(T.chevron))
|
||||
}
|
||||
}
|
||||
|
||||
private val READINESS_NAMES = listOf("Request", "Download", "Library", "Playback")
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
// 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
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
/** Household acquisition stages, ordered independently from their display wording. */
|
||||
enum class ComingInStage(val label: String, val rank: Int, val glyph: String, val readiness: List<String>) {
|
||||
Failed("Failed", 0, "alert", listOf("Done", "Failed", "Not yet", "Not yet")),
|
||||
Downloading("Downloading", 1, "download", listOf("Done", "Downloading", "Not yet", "Not yet")),
|
||||
AlmostReady("Almost ready", 1, "download", listOf("Done", "Done", "Being added", "Not yet")),
|
||||
Checking("Checking", 2, "clock", listOf("Checking", "Unknown", "Not yet", "Not yet")),
|
||||
Searching("Searching", 2, "clock", listOf("Done", "Searching", "Not yet", "Not yet")),
|
||||
Waiting("Waiting", 3, "clock", listOf("Waiting", "Not yet", "Not yet", "Not yet")),
|
||||
Ready("Ready", 4, "check", listOf("Done", "Done", "Done", "Not checked yet")),
|
||||
}
|
||||
|
||||
/** Ratings have already passed the supplier's observed-score and vote checks. */
|
||||
data class ComingInRating(val mark: String, val score: Double, val percentage: Boolean = false)
|
||||
|
||||
/** Requests carry authenticated actor evidence, never the current viewer as a substitute. */
|
||||
data class ComingInRequest(
|
||||
val id: String,
|
||||
val mediaType: String,
|
||||
val tmdbId: Long,
|
||||
val title: String,
|
||||
val requesterKey: String,
|
||||
val requesterName: String,
|
||||
val actorKey: String?,
|
||||
val actorName: String?,
|
||||
val stage: ComingInStage,
|
||||
val poster: Any? = null,
|
||||
val ratings: List<ComingInRating> = emptyList(),
|
||||
) {
|
||||
val ratingKey = "$mediaType:$tmdbId"
|
||||
fun recipient(viewer: ShellPerson): String = if (requesterKey == viewer.key) "you" else requesterName
|
||||
fun actor(viewer: ShellPerson): String = when {
|
||||
actorKey == "shared" -> "Shared"
|
||||
actorKey != null && actorKey == viewer.key -> "you"
|
||||
else -> actorName ?: "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
enum class ComingInStatus { Ready, Loading, Unavailable }
|
||||
|
||||
/** The provider retains saved rows when refresh fails; an unavailable empty result is not empty success. */
|
||||
data class ComingInSnapshot(
|
||||
val items: List<ComingInRequest> = emptyList(),
|
||||
val status: ComingInStatus = ComingInStatus.Unavailable,
|
||||
val partial: Boolean = false,
|
||||
val signedIn: Boolean = false,
|
||||
) {
|
||||
val ordered = items.sortedBy { it.stage.rank }
|
||||
val openCount: Int get() = items.count { it.stage != ComingInStage.Ready }
|
||||
}
|
||||
|
||||
/** A bounded window and stable request id preserve focus across refreshes and title round trips. */
|
||||
class ComingInState {
|
||||
var snapshot by mutableStateOf(ComingInSnapshot())
|
||||
private set
|
||||
var focusedId by mutableStateOf<String?>(null)
|
||||
private set
|
||||
var expanded by mutableStateOf(false)
|
||||
private set
|
||||
var ratingsVisible by mutableStateOf(false)
|
||||
private set
|
||||
private val cachedRatings = mutableSetOf<String>()
|
||||
val index: Int get() = snapshot.ordered.indexOfFirst { it.id == focusedId }.coerceAtLeast(0)
|
||||
val focused: ComingInRequest? get() = snapshot.ordered.getOrNull(index)
|
||||
val windowSize: Int get() = if (expanded) 4 else 5
|
||||
val windowStart: Int get() = (index - 2).coerceIn(0, (snapshot.ordered.size - windowSize).coerceAtLeast(0))
|
||||
|
||||
fun update(value: ComingInSnapshot) {
|
||||
snapshot = value
|
||||
if (!value.signedIn) cachedRatings.clear()
|
||||
if (value.ordered.none { it.id == focusedId }) focus(value.ordered.firstOrNull()?.id)
|
||||
ratingsVisible = value.signedIn && focused?.ratingKey in cachedRatings
|
||||
}
|
||||
|
||||
fun focus(id: String?) {
|
||||
focusedId = id
|
||||
expanded = false
|
||||
ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings
|
||||
}
|
||||
|
||||
/** Coroutine cancellation and the captured request id prevent stale focus reads from painting. */
|
||||
suspend fun awaitFocusedRatings() {
|
||||
val item = focused ?: return
|
||||
if (!snapshot.signedIn || snapshot.status != ComingInStatus.Ready || item.ratings.isEmpty() || ratingsVisible) return
|
||||
delay(WolfskinTokens.Motion.ratingDwell.toLong())
|
||||
if (focusedId != item.id || !snapshot.signedIn || snapshot.status != ComingInStatus.Ready) return
|
||||
cachedRatings.add(item.ratingKey)
|
||||
ratingsVisible = true
|
||||
}
|
||||
|
||||
/** Return true only when a row requests a title; every direction remains trapped in the sheet. */
|
||||
fun key(key: ShellKey): Boolean {
|
||||
when (key) {
|
||||
ShellKey.Up -> focus(snapshot.ordered.getOrNull((index - 1).coerceAtLeast(0))?.id)
|
||||
ShellKey.Down -> focus(snapshot.ordered.getOrNull((index + 1).coerceAtMost(snapshot.ordered.lastIndex))?.id)
|
||||
ShellKey.Right -> if (focused != null) expanded = true
|
||||
ShellKey.Left -> expanded = false
|
||||
ShellKey.Ok -> return focused != null
|
||||
else -> Unit
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.ui.text.style.LineHeightStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
||||
/** CSS line boxes retain their leading even on single lines; trimming changes sheet geometry. */
|
||||
internal object ComingInTypography {
|
||||
private val lineBox = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None)
|
||||
val title = WolfskinTokens.Type.sheetTitle.copy(lineHeightStyle = lineBox)
|
||||
val listTitle = WolfskinTokens.Type.listTitle.copy(lineHeightStyle = lineBox)
|
||||
val body = WolfskinTokens.Type.body.copy(lineHeightStyle = lineBox)
|
||||
val label = WolfskinTokens.Type.label.copy(lineHeightStyle = lineBox)
|
||||
val caption = WolfskinTokens.Type.caption.copy(lineHeightStyle = lineBox)
|
||||
val stateTitle = WolfskinTokens.Type.stateTitle.copy(lineHeightStyle = lineBox, fontWeight = FontWeight.Bold)
|
||||
val readiness = caption.copy(fontWeight = FontWeight.Bold)
|
||||
val rating = WolfskinTokens.Type.label.copy(fontFeatureSettings = "tnum")
|
||||
}
|
||||
|
|
@ -20,6 +20,9 @@ fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
|
|||
"seerr" -> R.drawable.wolfskin_sparkle
|
||||
"advanced" -> R.drawable.wolfskin_sort
|
||||
"alert" -> R.drawable.wolfskin_alert
|
||||
"clock" -> R.drawable.wolfskin_clock
|
||||
"check" -> R.drawable.wolfskin_check
|
||||
"download" -> R.drawable.wolfskin_download
|
||||
else -> R.drawable.wolfskin_right
|
||||
}
|
||||
Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color)
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ val SETTINGS_GROUPS = listOf(
|
|||
SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"),
|
||||
)
|
||||
|
||||
/** Requests is intentionally a no-data Wolfskin entry until the Requests slice. */
|
||||
/** The household request count shares the sheet's snapshot and excludes Ready. */
|
||||
@Composable
|
||||
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests: Int? = null) {
|
||||
val list = state.view.listState
|
||||
|
|
@ -77,7 +77,7 @@ fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests:
|
|||
}
|
||||
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
|
||||
Box(Modifier.height(L.row_srow)) {
|
||||
SettingsRow(group, !state.navFocused && index == state.settingsRow,
|
||||
SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow,
|
||||
if (group.key == "requests") openRequests else null,
|
||||
Modifier.offset(L.safeSide, S.settingsRowTop)) {
|
||||
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ sealed interface ShellAction {
|
|||
data class HandOff(val group: String) : ShellAction
|
||||
data object Pop : ShellAction
|
||||
data object RetryPeople : ShellAction
|
||||
data object RetryRequests : ShellAction
|
||||
data class RequestTitle(val request: ComingInRequest) : ShellAction
|
||||
}
|
||||
|
||||
/** D-pad inputs are shared by Android events and deterministic JVM replays. */
|
||||
|
|
@ -48,6 +50,8 @@ class ShellState(
|
|||
var peopleStatus by mutableStateOf(PeopleStatus.Ready)
|
||||
var recoveryIndex by mutableStateOf(0)
|
||||
var requestsVisible by mutableStateOf(false)
|
||||
val requests = ComingInState()
|
||||
private var requestReturnRoute: String? = null
|
||||
var handOff by mutableStateOf<String?>(null)
|
||||
var settingsRow by mutableStateOf(0)
|
||||
private var rootRoute by mutableStateOf("home")
|
||||
|
|
@ -63,6 +67,10 @@ class ShellState(
|
|||
route = destination
|
||||
if (destination in tabs) rootRoute = destination
|
||||
handOff = null
|
||||
if (requestReturnRoute == destination) {
|
||||
requestsVisible = true
|
||||
requestReturnRoute = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Pointer navigation uses the same transitions as D-pad selection. */
|
||||
|
|
@ -86,7 +94,12 @@ class ShellState(
|
|||
armedCard = null
|
||||
}
|
||||
if (requestsVisible) {
|
||||
if (key == ShellKey.Back || key == ShellKey.Ok) requestsVisible = false
|
||||
when {
|
||||
key == ShellKey.Back -> closeRequests()
|
||||
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Unavailable && requests.snapshot.items.isEmpty() -> retryRequests()
|
||||
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Loading && requests.snapshot.items.isEmpty() -> closeRequests()
|
||||
requests.key(key) -> activateRequest()
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (pickerVisible) pickerKey(key) else if (key == ShellKey.Back) back() else if (navFocused) navKey(key) else contentKey(key)
|
||||
|
|
@ -135,6 +148,8 @@ class ShellState(
|
|||
navFocused = false
|
||||
navIndex = 0
|
||||
rootRoute = "home"
|
||||
requestReturnRoute = null
|
||||
requestsVisible = false
|
||||
views[person.key to route] = RememberedView()
|
||||
onAction(ShellAction.Person(selected))
|
||||
}
|
||||
|
|
@ -172,6 +187,7 @@ class ShellState(
|
|||
gearFocused = false
|
||||
} else {
|
||||
val target = tabs[navIndex]
|
||||
requestReturnRoute = null
|
||||
if (target == route) views[person.key to target] = RememberedView()
|
||||
route = target
|
||||
rootRoute = target
|
||||
|
|
@ -180,15 +196,41 @@ class ShellState(
|
|||
}
|
||||
} else if (route == "settings") {
|
||||
val group = SETTINGS_GROUPS[settingsRow].key
|
||||
if (group == "requests") requestsVisible = true else {
|
||||
if (group == "requests") openRequests() else {
|
||||
handOff = group
|
||||
onAction(ShellAction.HandOff(group))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Requested buttons may name an existing request without changing its authenticated actor. */
|
||||
fun openRequests(requestId: String? = null) {
|
||||
requests.focus(requestId ?: requests.snapshot.ordered.firstOrNull()?.id)
|
||||
requestsVisible = true
|
||||
navFocused = false
|
||||
}
|
||||
|
||||
fun closeRequests() { requestsVisible = false }
|
||||
fun retryRequests() { onAction(ShellAction.RetryRequests) }
|
||||
|
||||
fun activateRequest() {
|
||||
val request = requests.focused ?: return
|
||||
requestReturnRoute = route
|
||||
requestsVisible = false
|
||||
onAction(ShellAction.RequestTitle(request))
|
||||
}
|
||||
|
||||
/** Back never discards a root view, including the card below a focused nav. */
|
||||
fun back() {
|
||||
if (requestsVisible) { closeRequests(); return }
|
||||
val returnRoute = requestReturnRoute
|
||||
if (returnRoute != null && route != returnRoute) {
|
||||
route = returnRoute
|
||||
requestsVisible = true
|
||||
requestReturnRoute = null
|
||||
onAction(ShellAction.Pop)
|
||||
return
|
||||
}
|
||||
if (route == "settings") {
|
||||
route = pickerReturnRoute
|
||||
onAction(ShellAction.Pop)
|
||||
|
|
|
|||
|
|
@ -82,20 +82,9 @@ fun ShellSurface(
|
|||
if (state.pickerVisible) {
|
||||
WhosWatching(state)
|
||||
} else {
|
||||
if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
||||
if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests ?: state.requests.snapshot.openCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
||||
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
|
||||
if (state.requestsVisible) {
|
||||
Box(Modifier.fillMaxSize().background(C.sheetScrim)) {
|
||||
Column(Modifier.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth)
|
||||
.width(L.sheetWidth).fillMaxSize().background(C.surface).padding(L.safeSide),
|
||||
verticalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
Text("Requests", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
|
||||
Text("Wolfskin screen", color = C.accentText, style = WolfskinTokens.Type.label)
|
||||
Text("No request data yet. Conductarr sign-in is not available on this TV.", color = C.muted, style = WolfskinTokens.Type.body)
|
||||
PickerButton("Close", true, true) { state.requestsVisible = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.requestsVisible) ComingInSheet(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,6 +229,42 @@ object WolfskinTokens {
|
|||
const val requestsPollSeconds = 30.0f
|
||||
const val comingInVisibleRows = 5.0f
|
||||
}
|
||||
object Requests {
|
||||
val sheetPaddingY = 40.0.dp
|
||||
val sheetPaddingX = 32.0.dp
|
||||
val sheetGap = 9.0.dp
|
||||
val emptyMargin = 10.0.dp
|
||||
val rowPaddingY = 7.0.dp
|
||||
val rowPaddingX = 9.0.dp
|
||||
val rowGap = 12.0.dp
|
||||
val listGap = 3.0.dp
|
||||
val stageMargin = 2.0.dp
|
||||
val stageGlyph = 14.0.dp
|
||||
val stageGap = 4.0.dp
|
||||
val readinessGapX = 11.0.dp
|
||||
val readinessGapY = 5.0.dp
|
||||
val readinessMargin = 4.0.dp
|
||||
val readinessGlyph = 13.0.dp
|
||||
val chevron = 15.0.dp
|
||||
const val chevronAlpha = 0.6f
|
||||
const val separatorAlpha = 0.4f
|
||||
val receiptPaddingY = 14.0.dp
|
||||
val receiptPaddingX = 15.0.dp
|
||||
val receiptGap = 5.0.dp
|
||||
val receiptGlyph = 18.0.dp
|
||||
val buttonMargin = 6.0.dp
|
||||
val skeletonTitleWidth = 180.0.dp
|
||||
val skeletonTitleHeight = 17.0.dp
|
||||
val skeletonSubtitleWidth = 120.0.dp
|
||||
val skeletonSubtitleHeight = 13.0.dp
|
||||
val skeletonGap = 6.0.dp
|
||||
val ratingsGap = 10.0.dp
|
||||
val ratingsEntryGap = 4.0.dp
|
||||
val shadowBlur = 40.0.dp
|
||||
val shadowOffset = -15.0.dp
|
||||
val shadowColor = ComposeColor(0x80000000)
|
||||
const val ratingsWordmarkRatio = 1.8f
|
||||
}
|
||||
object Shell {
|
||||
const val navLabelFade = 200
|
||||
val zero = 0.0.dp
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 804 B |
|
After Width: | Height: | Size: 813 B |
|
After Width: | Height: | Size: 964 B |
|
After Width: | Height: | Size: 1 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1,023 B |
3
wolfskin-design/src/main/res/drawable/wolfskin_check.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M5,12.5l4.5,4.5L19,7.5" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round"/>
|
||||
</vector>
|
||||
3
wolfskin-design/src/main/res/drawable/wolfskin_clock.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M20.5,12a8.5,8.5 0,1 1,-17,0a8.5,8.5 0,1 1,17,0M12,7.5V12l3,2" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" android:strokeLineCap="round"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M12,4v11m-5,-5l5,5l5,-5M5,20h14" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" android:strokeLineJoin="round"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
/** The fake coroutine clock proves the production dwell/cache decision without a Compose scheduler. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ComingInRatingsGateTest {
|
||||
private val first = ComingInRequest("first", "movie", 1, "First", "justin", "Justin", "justin", "Justin", ComingInStage.Waiting,
|
||||
ratings = listOf(ComingInRating("imdb", 6.0)))
|
||||
private val second = first.copy(id = "second", tmdbId = 2, title = "Second", ratings = listOf(ComingInRating("tmdb", 7.2)))
|
||||
|
||||
@Test fun uncachedDwellCancelsOnLeaveAndReentryStartsAFreshRead() = runTest {
|
||||
val state = ComingInState()
|
||||
state.update(ComingInSnapshot(listOf(first, second), ComingInStatus.Ready, signedIn = true))
|
||||
val abandoned = launch { state.awaitFocusedRatings() }
|
||||
runCurrent()
|
||||
advanceTimeBy(100)
|
||||
assertFalse(state.ratingsVisible)
|
||||
abandoned.cancel()
|
||||
state.focus(second.id)
|
||||
val next = launch { state.awaitFocusedRatings() }
|
||||
runCurrent()
|
||||
advanceTimeBy(349)
|
||||
runCurrent()
|
||||
assertFalse(state.ratingsVisible)
|
||||
advanceTimeBy(1)
|
||||
runCurrent()
|
||||
assertTrue(state.ratingsVisible)
|
||||
next.join()
|
||||
state.focus(first.id)
|
||||
assertFalse(state.ratingsVisible)
|
||||
launch { state.awaitFocusedRatings() }
|
||||
runCurrent()
|
||||
advanceTimeBy(350)
|
||||
runCurrent()
|
||||
assertTrue("Returning to the abandoned uncached title must complete a new dwell", state.ratingsVisible)
|
||||
state.focus(second.id)
|
||||
assertTrue("A previously read title must be visible on the focus transition itself", state.ratingsVisible)
|
||||
}
|
||||
|
||||
@Test fun signOutClearsCachedVisibilityAndSignInUsesFreshRatingAndLocale() = runTest {
|
||||
val state = ComingInState()
|
||||
val snapshot = ComingInSnapshot(listOf(first), ComingInStatus.Ready, signedIn = true)
|
||||
state.update(snapshot)
|
||||
launch { state.awaitFocusedRatings() }
|
||||
runCurrent(); advanceTimeBy(350); runCurrent()
|
||||
assertTrue(state.ratingsVisible)
|
||||
state.update(snapshot.copy(signedIn = false))
|
||||
assertFalse(state.ratingsVisible)
|
||||
val refreshed = first.copy(ratings = listOf(ComingInRating("imdb", 7.6)))
|
||||
state.update(snapshot.copy(items = listOf(refreshed)))
|
||||
assertFalse("Sign-out invalidates the previous session's rating gate", state.ratingsVisible)
|
||||
launch { state.awaitFocusedRatings() }
|
||||
runCurrent(); advanceTimeBy(350); runCurrent()
|
||||
assertTrue(state.ratingsVisible)
|
||||
val rating = state.focused!!.ratings.single()
|
||||
assertEquals("7,6", formatComingInRating(rating, Locale.forLanguageTag("nl-NL")))
|
||||
assertEquals("7.6", formatComingInRating(rating, Locale.ENGLISH))
|
||||
assertEquals("87%", formatComingInRating(ComingInRating("fresh", 87.9, true), Locale.forLanguageTag("nl-NL")))
|
||||
}
|
||||
|
||||
@Test fun savedOfflineRowsOnlyShowPreviouslyReadRatingsAcrossHouseholdRequests() = runTest {
|
||||
val state = ComingInState()
|
||||
val sharedCopy = first.copy(id = "other-request", requesterKey = "shared", requesterName = "Shared")
|
||||
val online = ComingInSnapshot(listOf(first, second, sharedCopy), ComingInStatus.Ready, signedIn = true)
|
||||
state.update(online)
|
||||
launch { state.awaitFocusedRatings() }
|
||||
runCurrent(); advanceTimeBy(350); runCurrent()
|
||||
state.update(online.copy(status = ComingInStatus.Unavailable))
|
||||
state.focus(second.id)
|
||||
state.awaitFocusedRatings()
|
||||
assertFalse("An unread title cannot acquire ratings while offline", state.ratingsVisible)
|
||||
state.focus(sharedCopy.id)
|
||||
assertTrue("The title cache is shared across request rows for the same media type and TMDB id", state.ratingsVisible)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
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
|
||||
|
||||
/** Every approved Requests state uses the actual shell overlay at native television density. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class RequestsCaptureTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@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, snapshot: ComingInSnapshot, person: String = "justin", expanded: Boolean = false) {
|
||||
val state = ShellState(fixturePeople(), person)
|
||||
state.pickerVisible = false
|
||||
state.enterDestination("settings")
|
||||
state.requests.update(snapshot)
|
||||
state.openRequests()
|
||||
if (expanded) { state.requests.key(ShellKey.Down); state.requests.key(ShellKey.Right) }
|
||||
rule.setContent {
|
||||
WolfskinTheme { ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) }
|
||||
}
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory"))
|
||||
directory.mkdirs()
|
||||
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
|
||||
}
|
||||
|
||||
@Test fun st081_empty() = capture("st-081", ComingInSnapshot(status = ComingInStatus.Ready, signedIn = true))
|
||||
@Test fun st082_stages() = capture("st-082", requestsFixture())
|
||||
@Test fun st083_unavailable() = capture("st-083", ComingInSnapshot())
|
||||
@Test fun st084_loading() = capture("st-084", ComingInSnapshot(status = ComingInStatus.Loading))
|
||||
@Test fun st085_partial() = capture("st-085", requestsFixture().copy(items = requestsFixture().items.take(3), partial = true))
|
||||
@Test fun st086_readiness() = capture("st-086", requestsFixture(), expanded = true)
|
||||
@Test fun st088_sharedActor() = capture("st-088", requestsFixture(shared = true), person = "shared")
|
||||
@Test fun st111_focusedRatings() = capture("st-111", requestsFixture(ratings = true))
|
||||
}
|
||||
|
||||
/** Shared actor and stage fixtures are projections, never network calls or synthetic UI redraws. */
|
||||
fun requestsFixture(shared: Boolean = false, ratings: Boolean = false): ComingInSnapshot {
|
||||
val source = RequestsCaptureTest::class.java.getResourceAsStream("/wolfskin-requests.json")!!.bufferedReader().use { it.readText() }
|
||||
val items = readComingInFixture(source) { name ->
|
||||
RequestsCaptureTest::class.java.getResourceAsStream("/wolfskin-requests/$name")!!.use { it.readBytes() }
|
||||
}
|
||||
val selected = when { shared -> items.filter { it.id == "x7" }; ratings -> items.filter { it.id == "x8" }; else -> items.take(6) }
|
||||
return ComingInSnapshot(selected, ComingInStatus.Ready, signedIn = true)
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import java.util.Locale
|
||||
import org.json.JSONArray
|
||||
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
|
||||
|
||||
/** Key replays exercise real shell handling, including trapped overlays and title-return memory. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class RequestsKeysTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
|
||||
private fun mount(state: ShellState) {
|
||||
rule.setContent { WolfskinTheme { ShellSurface(state) } }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
}
|
||||
|
||||
private fun send(first: Key, second: Key? = null, third: Key? = null, fourth: Key? = null) {
|
||||
listOfNotNull(first, second, third, fourth).forEach { key ->
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
}
|
||||
}
|
||||
|
||||
private fun sheet(snapshot: ComingInSnapshot = requestsFixture(), actions: (ShellAction) -> Unit = {}): ShellState =
|
||||
ShellState(fixturePeople(), "justin", actions).apply {
|
||||
pickerVisible = false
|
||||
enterDestination("settings")
|
||||
requests.update(snapshot)
|
||||
openRequests()
|
||||
}
|
||||
|
||||
@Test fun approvedNinePressPathUsesRealShellFocus() {
|
||||
val paths = RequestsKeysTest::class.java.getResourceAsStream("/requests-press-paths.json")!!.bufferedReader().use { JSONArray(it.readText()) }
|
||||
val keys = mapOf("OK" to Key.DirectionCenter, "UP" to Key.DirectionUp, "LEFT" to Key.DirectionLeft,
|
||||
"RIGHT" to Key.DirectionRight, "DOWN" to Key.DirectionDown)
|
||||
val state = ShellState(fixturePeople(), "justin")
|
||||
state.requests.update(requestsFixture())
|
||||
mount(state)
|
||||
val path = paths.getJSONObject(0)
|
||||
val presses = path.getJSONArray("keys")
|
||||
assertEquals(9, path.getInt("presses"))
|
||||
assertEquals(path.getInt("presses"), presses.length())
|
||||
for (index in 0 until presses.length()) send(keys.getValue(presses.getString(index)))
|
||||
rule.onNodeWithTag("coming-in").assertExists()
|
||||
rule.onNodeWithText("Requests · 5 open").assertExists()
|
||||
assertEquals("settings", state.route)
|
||||
assertEquals("x5", state.requests.focusedId)
|
||||
assertFalse(state.navFocused)
|
||||
}
|
||||
|
||||
@Test fun readinessWindowAndTitleRoundTripPreserveRequest() {
|
||||
lateinit var state: ShellState
|
||||
state = sheet { action -> if (action is ShellAction.RequestTitle) state.enterDestination("request-title/${action.request.tmdbId}") }
|
||||
mount(state)
|
||||
send(Key.DirectionDown, Key.DirectionRight)
|
||||
rule.onNodeWithText("Download · Downloading").assertExists()
|
||||
assertEquals(4, state.requests.windowSize)
|
||||
assertEquals("x1", state.requests.focusedId)
|
||||
send(Key.DirectionLeft)
|
||||
rule.onNodeWithText("Download · Downloading").assertDoesNotExist()
|
||||
send(Key.DirectionDown, Key.DirectionDown, Key.DirectionDown, Key.DirectionDown)
|
||||
assertEquals("x6", state.requests.focusedId)
|
||||
assertEquals(1, state.requests.windowStart)
|
||||
send(Key.DirectionDown)
|
||||
assertEquals("x6", state.requests.focusedId)
|
||||
send(Key.DirectionCenter)
|
||||
assertFalse(state.requestsVisible)
|
||||
assertTrue(state.route.startsWith("request-title/"))
|
||||
send(Key.Back)
|
||||
rule.onNodeWithTag("coming-row-x6").assertExists()
|
||||
assertEquals("x6", state.requests.focusedId)
|
||||
assertTrue(state.requestsVisible)
|
||||
send(Key.Back)
|
||||
assertFalse(state.requestsVisible)
|
||||
assertEquals("settings", state.route)
|
||||
assertEquals(0, state.settingsRow)
|
||||
}
|
||||
|
||||
@Test fun emptyAndLoadingTrapDirectionsWhileUnavailableRetries() {
|
||||
var retries = 0
|
||||
val state = sheet(ComingInSnapshot(status = ComingInStatus.Loading)) { if (it == ShellAction.RetryRequests) retries++ }
|
||||
mount(state)
|
||||
send(Key.DirectionUp, Key.DirectionLeft)
|
||||
assertFalse(state.navFocused)
|
||||
assertTrue(state.requestsVisible)
|
||||
send(Key.DirectionCenter)
|
||||
assertFalse(state.requestsVisible)
|
||||
rule.runOnIdle { state.requests.update(ComingInSnapshot()); state.openRequests() }
|
||||
send(Key.DirectionCenter)
|
||||
assertEquals(1, retries)
|
||||
assertTrue(state.requestsVisible)
|
||||
rule.runOnIdle { state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready)) }
|
||||
send(Key.DirectionDown, Key.DirectionCenter)
|
||||
rule.onNodeWithText("Nothing coming in.").assertExists()
|
||||
assertTrue(state.requestsVisible)
|
||||
}
|
||||
|
||||
@Test fun cachedRatingsRenderInTheTelevisionLocale() {
|
||||
val original = Locale.getDefault()
|
||||
try {
|
||||
Locale.setDefault(Locale.forLanguageTag("nl-NL"))
|
||||
val state = sheet(requestsFixture(ratings = true))
|
||||
kotlinx.coroutines.test.runTest { state.requests.awaitFocusedRatings() }
|
||||
mount(state)
|
||||
rule.onNodeWithText("7,6").assertExists()
|
||||
rule.onNodeWithText("87%").assertExists()
|
||||
rule.runOnIdle { state.requests.update(state.requests.snapshot.copy(signedIn = false)) }
|
||||
rule.onNodeWithText("7,6").assertDoesNotExist()
|
||||
rule.onNodeWithText("87%").assertDoesNotExist()
|
||||
} finally { Locale.setDefault(original) }
|
||||
}
|
||||
|
||||
@Test fun selectingARootAfterARequestTitleDiscardsTheSheetReturnLink() {
|
||||
lateinit var state: ShellState
|
||||
state = sheet { action -> if (action is ShellAction.RequestTitle) state.enterDestination("request-title/${action.request.tmdbId}") }
|
||||
mount(state)
|
||||
send(Key.DirectionCenter, Key.DirectionUp, Key.DirectionCenter)
|
||||
assertEquals("home", state.route)
|
||||
send(Key.Back)
|
||||
assertFalse("Root Back must focus navigation, not reopen the abandoned sheet", state.requestsVisible)
|
||||
assertTrue(state.navFocused)
|
||||
}
|
||||
|
||||
@Test fun refreshKeepsStableFocusAndSharedActorNeverBecomesYou() {
|
||||
val snapshot = requestsFixture()
|
||||
val state = sheet(snapshot)
|
||||
mount(state)
|
||||
send(Key.DirectionDown)
|
||||
rule.runOnIdle { state.requests.update(snapshot.copy(items = snapshot.items.reversed(), partial = true)) }
|
||||
assertEquals("x1", state.requests.focusedId)
|
||||
rule.onNodeWithText("Some requests may be missing: Conductarr answered only in part.").assertExists()
|
||||
val shared = requestsFixture(shared = true).items.single()
|
||||
val viewer = fixturePeople().single { it.key == "shared" }
|
||||
assertEquals("Shared", shared.actor(viewer))
|
||||
assertEquals("Justin", shared.recipient(viewer))
|
||||
assertEquals(5, snapshot.openCount)
|
||||
assertEquals(listOf(0, 1, 1, 2, 3, 4), snapshot.ordered.map { it.stage.rank })
|
||||
}
|
||||
}
|
||||
21
wolfskin-design/src/test/resources/requests-press-paths.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[
|
||||
{
|
||||
"goal": "Round 4 \u00b7 Home hero \u2192 Requests (Coming in) in Settings",
|
||||
"keys": [
|
||||
"OK",
|
||||
"UP",
|
||||
"LEFT",
|
||||
"OK",
|
||||
"RIGHT",
|
||||
"RIGHT",
|
||||
"DOWN",
|
||||
"OK",
|
||||
"OK"
|
||||
],
|
||||
"presses": 9,
|
||||
"note": "8 from the hero",
|
||||
"start": "app launch: Who's watching with Justin focused (index.html:357)",
|
||||
"replayed": "PASS",
|
||||
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
|
||||
}
|
||||
]
|
||||
217
wolfskin-design/src/test/resources/wolfskin-requests.json
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
{
|
||||
"source": "Locked Opus fixture/fixture.json; index.html EXAMPLES() pool order",
|
||||
"items": [
|
||||
{
|
||||
"id": "x1",
|
||||
"mediaType": "tv",
|
||||
"tmdbId": 11821,
|
||||
"title": "The Abbott and Costello Show",
|
||||
"requesterKey": "justin",
|
||||
"requesterName": "Justin",
|
||||
"actorKey": "justin",
|
||||
"actorName": "Justin",
|
||||
"stage": "Downloading",
|
||||
"poster": "tv-the-abbott-and-costello-show-1952-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "tmdb",
|
||||
"score": 7.2,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x2",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 13483,
|
||||
"title": "Awake",
|
||||
"requesterKey": "mindel",
|
||||
"requesterName": "Mindel",
|
||||
"actorKey": "justin",
|
||||
"actorName": "Justin",
|
||||
"stage": "AlmostReady",
|
||||
"poster": "movie-awake-2007-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "rotten",
|
||||
"score": 23,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "spilled",
|
||||
"score": 55,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 6.5,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x3",
|
||||
"mediaType": "tv",
|
||||
"tmdbId": 70785,
|
||||
"title": "Anne with an E",
|
||||
"requesterKey": "shared",
|
||||
"requesterName": "Shared",
|
||||
"actorKey": "mindel",
|
||||
"actorName": "Mindel",
|
||||
"stage": "Searching",
|
||||
"poster": "tv-anne-with-an-e-2017-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "fresh",
|
||||
"score": 63,
|
||||
"percentage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x4",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 99861,
|
||||
"title": "Avengers: Age of Ultron",
|
||||
"requesterKey": "justin",
|
||||
"requesterName": "Justin",
|
||||
"actorKey": "justin",
|
||||
"actorName": "Justin",
|
||||
"stage": "Waiting",
|
||||
"poster": "movie-avengers-age-of-ultron-2015-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "fresh",
|
||||
"score": 75,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "upright",
|
||||
"score": 82,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 7.3,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x5",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 1033148,
|
||||
"title": "Die My Love",
|
||||
"requesterKey": "mindel",
|
||||
"requesterName": "Mindel",
|
||||
"actorKey": "mindel",
|
||||
"actorName": "Mindel",
|
||||
"stage": "Failed",
|
||||
"poster": "movie-die-my-love-2025-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "fresh",
|
||||
"score": 74,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "spilled",
|
||||
"score": 45,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 6,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x6",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 416153,
|
||||
"title": "Arctic Dogs",
|
||||
"requesterKey": "shared",
|
||||
"requesterName": "Shared",
|
||||
"actorKey": "justin",
|
||||
"actorName": "Justin",
|
||||
"stage": "Ready",
|
||||
"poster": "movie-arctic-dogs-2019-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "rotten",
|
||||
"score": 13,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "upright",
|
||||
"score": 60,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 4.9,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x7",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 76203,
|
||||
"title": "12 Years a Slave",
|
||||
"requesterKey": "justin",
|
||||
"requesterName": "Justin",
|
||||
"actorKey": "shared",
|
||||
"actorName": "Shared",
|
||||
"stage": "Waiting",
|
||||
"poster": "movie-12-years-a-slave-2013-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "fresh",
|
||||
"score": 95,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "upright",
|
||||
"score": 90,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 8.1,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x8",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 14069,
|
||||
"title": "The Girl Who Leapt Through Time",
|
||||
"requesterKey": "mindel",
|
||||
"requesterName": "Mindel",
|
||||
"actorKey": "justin",
|
||||
"actorName": "Justin",
|
||||
"stage": "Waiting",
|
||||
"poster": "movie-the-girl-who-leapt-through-time-2006-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "fresh",
|
||||
"score": 87,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "upright",
|
||||
"score": 90,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 7.6,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 24 KiB |