Present watched recommendation seeds and request history truthfully
All checks were successful
review/code-gpt Firstmate read the PR evidence: TV-only labels and seed grouping, recorded-wire acceptance matrix, fixtures contain no secrets (checked), native before/after frames, full serial suite 697/190/17 green plus apk-debug; no tokens, ring or spacing changes; device check follows
review/design-claude Firstmate read the PR evidence: TV-only labels and seed grouping, recorded-wire acceptance matrix, fixtures contain no secrets (checked), native before/after frames, full serial suite 697/190/17 green plus apk-debug; no tokens, ring or spacing changes; device check follows
All checks were successful
review/code-gpt Firstmate read the PR evidence: TV-only labels and seed grouping, recorded-wire acceptance matrix, fixtures contain no secrets (checked), native before/after frames, full serial suite 697/190/17 green plus apk-debug; no tokens, ring or spacing changes; device check follows
review/design-claude Firstmate read the PR evidence: TV-only labels and seed grouping, recorded-wire acceptance matrix, fixtures contain no secrets (checked), native before/after frames, full serial suite 697/190/17 green plus apk-debug; no tokens, ring or spacing changes; device check follows
This commit is contained in:
parent
deb24e3c9f
commit
278335f009
21 changed files with 11092 additions and 49 deletions
|
|
@ -157,7 +157,8 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex
|
|||
Column(Modifier.weight(1f)) {
|
||||
Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline),
|
||||
style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text("for ${item.recipient(viewer)} · by ${item.actor(viewer)}", Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline),
|
||||
val actor = item.actor(viewer)
|
||||
Text("for ${item.recipient(viewer)}" + (actor?.let { " · by $it" } ?: ""), Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline),
|
||||
style = ComingInTypography.caption, color = secondary)
|
||||
FlowRow(Modifier.padding(top = T.stageMargin).heightIn(min = T.stageLineHeight), horizontalArrangement = Arrangement.spacedBy(T.readinessGlyph)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.stageGap)) {
|
||||
|
|
@ -168,14 +169,17 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex
|
|||
}
|
||||
if (expanded) FlowRow(Modifier.padding(top = T.readinessMargin),
|
||||
horizontalArrangement = Arrangement.spacedBy(T.readinessGapX), verticalArrangement = Arrangement.spacedBy(T.readinessGapY)) {
|
||||
item.stage.readiness.forEachIndexed { index, word ->
|
||||
item.readiness.forEachIndexed { index, word ->
|
||||
Row {
|
||||
val glyph = when (word) { "Done" -> "check"; "Failed" -> "alert"; "Not yet", "Unknown" -> null; else -> "clock" }
|
||||
val glyph = when (word) { "Done", "Files present", "Recorded play", "Another library" -> "check"; "Failed", "Check needed", "Needs attention" -> "alert"; "Not yet", "Unknown", "Not checked yet", "Not in library", "Removed", "Not out yet" -> null; else -> "clock" }
|
||||
if (glyph != null) Glyph(glyph, secondary, Modifier.size(T.readinessGlyph).alignBy { it.measuredHeight })
|
||||
Text("${READINESS_NAMES[index]} · $word", Modifier.alignByBaseline(), style = ComingInTypography.readiness, color = secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (expanded) item.summary?.let { summary ->
|
||||
Text(summary, Modifier.padding(top = T.readinessMargin), style = ComingInTypography.readiness, color = secondary)
|
||||
}
|
||||
}
|
||||
if (focused && !expanded) Glyph("right", foreground.copy(alpha = T.chevronAlpha), Modifier.size(T.chevron))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,14 +6,21 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.setValue
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
/** Household acquisition stages, ordered independently from their display wording. */
|
||||
/** History states distinguish acquisition work from library evidence and playback health. */
|
||||
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")),
|
||||
FileHealth("File health needs attention", 0, "alert", listOf("Done", "Files present", "See details", "Needs attention")),
|
||||
CheckNeeded("Check needed", 0, "alert", listOf("Done", "Files present", "Check needed", "Not checked 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")),
|
||||
WaitingForFile("Waiting for a file", 3, "clock", listOf("Done", "Waiting for a file", "Not yet", "Not checked yet")),
|
||||
NotOutYet("Not out yet", 3, "clock", listOf("Done", "Not out yet", "Not yet", "Not checked yet")),
|
||||
HeldElsewhere("In another library", 4, "check", listOf("Done", "Files present", "Another library", "Not checked yet")),
|
||||
Removed("Removed", 4, "clock", listOf("Done", "Removed", "Not in library", "Not checked yet")),
|
||||
NotInLibrary("Not in any library", 4, "clock", listOf("Done", "Not observed", "Not in library", "Not checked yet")),
|
||||
Ready("Ready", 4, "check", listOf("Done", "Done", "Done", "Not checked yet")),
|
||||
}
|
||||
|
||||
|
|
@ -33,13 +40,15 @@ data class ComingInRequest(
|
|||
val stage: ComingInStage,
|
||||
val poster: Any? = null,
|
||||
val ratings: List<ComingInRating> = emptyList(),
|
||||
val summary: String? = null,
|
||||
val readiness: List<String> = stage.readiness,
|
||||
) {
|
||||
val ratingKey = "$mediaType:$tmdbId"
|
||||
fun recipient(viewer: ShellPerson): String = if (requesterKey == viewer.key) "you" else requesterName
|
||||
fun actor(viewer: ShellPerson): String = when {
|
||||
fun actor(viewer: ShellPerson): String? = when {
|
||||
actorKey == "shared" -> "Shared"
|
||||
actorKey != null && actorKey == viewer.key -> "you"
|
||||
else -> actorName ?: "Unknown"
|
||||
else -> actorName?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -54,7 +63,13 @@ data class ComingInSnapshot(
|
|||
val savedAt: String? = null,
|
||||
) {
|
||||
val ordered = items.sortedBy { it.stage.rank }
|
||||
val openCount: Int get() = items.count { it.stage != ComingInStage.Ready }
|
||||
val requestCount: Int? get() = if (status == ComingInStatus.Ready || items.isNotEmpty()) items.size else null
|
||||
val hasUnsettledRequests: Boolean get() = items.any {
|
||||
when (it.stage) {
|
||||
ComingInStage.Ready, ComingInStage.Removed, ComingInStage.NotInLibrary, ComingInStage.HeldElsewhere, ComingInStage.NotOutYet -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A bounded window and stable request id preserve focus across refreshes and title round trips. */
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ class DiscoverFixture(json: String, private val artPath: (String) -> String = {
|
|||
val items = pool.filter { genre in normalizedGenres(it.genres) && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.type == "movie") }
|
||||
DiscoverLane("genre-$genre", genre, items.take(20), items.size, fullTitles = items)
|
||||
}
|
||||
return DiscoverSnapshot(picks, groups.values.map { DiscoverSeed(it.first, it.second.toList()) }, services, lanes, status = DiscoverStatus.Ready, reusesFixtureLanes = true)
|
||||
return DiscoverSnapshot(picks, groups.map { (key, group) -> DiscoverSeed(key, group.first, group.second.toList(), watched = true, added = false) },
|
||||
services, lanes, status = DiscoverStatus.Ready, reusesFixtureLanes = true)
|
||||
}
|
||||
|
||||
fun service(service: DiscoverService, type: String): List<DiscoverLane> {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List<Disco
|
|||
val result = mutableListOf<DiscoverLane>()
|
||||
fun take(items: List<DiscoverTitle>, limit: Int) = items.filter { matches(it) && it.key !in hidden && it.key !in seen }.take(limit).filter { seen.add(it.key) }
|
||||
if (snapshot.status == DiscoverStatus.Ready) {
|
||||
val groups = snapshot.seeds.map { seed -> seed.copy(items = seed.items.filter { matches(it) && it.key !in hidden }) }.filter { it.items.isNotEmpty() }.take(3)
|
||||
val groups = snapshot.seeds.asSequence().filter { it.watched && it.title.isNotBlank() }
|
||||
.map { seed -> seed.copy(items = seed.items.filter { matches(it) && it.key !in hidden }) }
|
||||
.filter { it.items.isNotEmpty() }.take(3).toList()
|
||||
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 }
|
||||
|
|
@ -18,9 +20,9 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List<Disco
|
|||
if (items.isNotEmpty()) result += DiscoverLane("pk-$type", "$name for you", items, all.size, pick = true, fullTitles = all)
|
||||
}
|
||||
val size = if (groups.isEmpty()) 0 else minOf(8, ceil(snapshot.picks.count { matches(it) && it.key !in seen && it.key !in hidden }.toDouble() / groups.size).toInt())
|
||||
groups.forEachIndexed { index, seed ->
|
||||
groups.forEach { seed ->
|
||||
val items = take(seed.items, size)
|
||||
if (items.size >= 3) result += DiscoverLane("pk-w$index", "Because you watched ${seed.title}", items, seed.items.size, pick = true, fullTitles = seed.items)
|
||||
if (items.size >= 3) result += DiscoverLane("pk-w-${seed.key}", "Because you watched ${seed.title}", items, seed.items.size, pick = true, fullTitles = seed.items)
|
||||
}
|
||||
}
|
||||
val displayed = heroes.mapTo(mutableSetOf()) { it.key }
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ data class DiscoverTitle(
|
|||
/** 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>)
|
||||
/** A typed seed identity and its contribution evidence retain wire order without inventing recency. */
|
||||
data class DiscoverSeed(val key: String, val title: String, val items: List<DiscoverTitle>, val watched: Boolean, val added: Boolean)
|
||||
|
||||
/** 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)
|
||||
|
|
|
|||
|
|
@ -52,9 +52,9 @@ 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"),
|
||||
)
|
||||
|
||||
/** The household request count shares the sheet's snapshot and excludes Ready. */
|
||||
/** The count describes returned request history, not an inferred queue of active downloads. */
|
||||
@Composable
|
||||
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests: Int? = null) {
|
||||
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null) {
|
||||
val list = state.view.listState
|
||||
val density = LocalDensity.current
|
||||
LaunchedEffect(state.settingsRow, state.navFocused) {
|
||||
|
|
@ -78,7 +78,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 && !state.requestsVisible && index == state.settingsRow,
|
||||
if (group.key == "requests") openRequests else null,
|
||||
if (group.key == "requests") requestCount else null,
|
||||
Modifier.offset(L.safeSide, S.settingsRowTop)) {
|
||||
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)
|
||||
}
|
||||
|
|
@ -89,7 +89,7 @@ fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests:
|
|||
|
||||
/** The inverse focus fill has no focus ring and never changes row layout. */
|
||||
@Composable
|
||||
fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
fun SettingsRow(group: SettingsGroup, focused: Boolean, requestCount: Int?, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
val foreground = if (focused) C.onFocus else C.fg
|
||||
Row(modifier.size(L.settingsRowWidth, L.settingsRowHeight).focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
||||
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
|
||||
|
|
@ -97,7 +97,7 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modi
|
|||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) {
|
||||
Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
|
||||
Column(Modifier.weight(1f)) {
|
||||
val count = if (openRequests != null && openRequests > 0) " · $openRequests open" else ""
|
||||
val count = requestCount?.let { " · $it ${if (it == 1) "request" else "requests"}" } ?: ""
|
||||
Text(group.title + count, Modifier.exactLineBox(WolfskinTokens.Type.listTitle), color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1)
|
||||
Text(group.description, Modifier.exactLineBox(WolfskinTokens.Type.caption), color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ fun ShellSurface(
|
|||
state: ShellState,
|
||||
modifier: Modifier = Modifier,
|
||||
handlesContentKeys: Boolean = true,
|
||||
settingsOpenRequests: Int? = null,
|
||||
settingsRequestCount: Int? = null,
|
||||
overlay: @Composable () -> Unit = {},
|
||||
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
|
||||
) {
|
||||
|
|
@ -91,7 +91,7 @@ fun ShellSurface(
|
|||
if (state.pickerVisible) {
|
||||
WhosWatching(state)
|
||||
} else {
|
||||
if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests ?: state.requests.snapshot.openCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
||||
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount) 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) ComingInSheet(state)
|
||||
overlay()
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import org.junit.Test
|
|||
class DiscoverLaneProjectionTest {
|
||||
@Test fun discardedSeedGroupRemainsAvailableInCatalogue() {
|
||||
val titles = (1L..6L).map { DiscoverTitle(it, "movie", "Film $it") }
|
||||
val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(DiscoverSeed("Seed", titles.subList(3, 5))),
|
||||
val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(DiscoverSeed("movie:10", "Seed", titles.subList(3, 5), watched = true, added = false)),
|
||||
lanes = listOf(DiscoverLane("trending", "Trending", titles)), status = DiscoverStatus.Ready)
|
||||
val rows = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap())
|
||||
assertEquals(listOf("pk-movie", "trending"), rows.map { it.id })
|
||||
|
|
@ -30,4 +30,25 @@ class DiscoverLaneProjectionTest {
|
|||
val filtered = projectDiscoverLanes(snapshot, emptyList(), "tv", mapOf(hidden.key to hidden))
|
||||
assertEquals(listOf(listOf(series)), filtered.map { it.titles })
|
||||
}
|
||||
|
||||
@Test fun addedOnlySeedsKeepTheirPicksWithoutClaimingTheyWereWatched() {
|
||||
val titles = (1L..9L).map { DiscoverTitle(it, "movie", "Film $it") }
|
||||
val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(
|
||||
DiscoverSeed("movie:10", "Added seed", titles, watched = false, added = true)), status = DiscoverStatus.Ready)
|
||||
val lanes = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap())
|
||||
assertEquals(listOf("Movies for you"), lanes.map { it.title })
|
||||
assertEquals(titles, lanes.single().fullTitles)
|
||||
}
|
||||
|
||||
@Test fun watchedLanesKeepWireOrderAndStableIdentityWhenTitlesCollide() {
|
||||
val titles = (1L..24L).map { DiscoverTitle(it, "movie", "Film $it") }
|
||||
val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(
|
||||
DiscoverSeed("movie:50", "Added seed", titles.take(8), watched = false, added = true),
|
||||
DiscoverSeed("tv:70", "Same title", titles.subList(8, 16), watched = true, added = true),
|
||||
DiscoverSeed("movie:70", "Same title", titles.subList(16, 24), watched = true, added = false)),
|
||||
status = DiscoverStatus.Ready)
|
||||
val lanes = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap())
|
||||
assertEquals(listOf("pk-movie", "pk-w-tv:70", "pk-w-movie:70"), lanes.map { it.id })
|
||||
assertEquals(listOf(titles.subList(8, 16), titles.subList(16, 24)), lanes.drop(1).map { it.titles })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ class RequestsKeysTest {
|
|||
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)
|
||||
|
|
@ -144,7 +143,5 @@ class RequestsKeysTest {
|
|||
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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,12 +38,12 @@ class ShellCaptureTest {
|
|||
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, openRequests: Int? = null, configure: (ShellState) -> Unit = {}) {
|
||||
private fun capture(name: String, requestCount: Int? = null, configure: (ShellState) -> Unit = {}) {
|
||||
val state = ShellState(fixturePeople(), "justin")
|
||||
configure(state)
|
||||
rule.setContent {
|
||||
WolfskinTheme {
|
||||
ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), settingsOpenRequests = openRequests)
|
||||
ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), settingsRequestCount = requestCount)
|
||||
}
|
||||
}
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
|
|
@ -67,7 +67,7 @@ class ShellCaptureTest {
|
|||
@Test fun st011_rememberedNav() = capture("st-011") { it.pickerVisible = false; it.navFocused = true; it.view.row = 4; it.view.cards["row-4"] = 2 }
|
||||
@Test fun st014_discoverNav() = capture("st-014") { it.pickerVisible = false; it.navFocused = true; it.navIndex = 1 }
|
||||
@Test fun st091_settings() = capture("st-091") { it.pickerVisible = false; it.enterDestination("settings") }
|
||||
@Test fun st092_requestsCountFixture() = capture("st-092", openRequests = 5) { it.pickerVisible = false; it.enterDestination("settings") }
|
||||
@Test fun st092_requestsCountFixture() = capture("st-092", requestCount = 5) { it.pickerVisible = false; it.enterDestination("settings") }
|
||||
@Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 }
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue