Build the Wolfskin TV Home feed #31
9 changed files with 125 additions and 45 deletions
|
|
@ -18,6 +18,7 @@ import io.mockk.mockk
|
|||
import java.util.UUID
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
|
@ -32,9 +33,25 @@ import org.robolectric.annotation.Config
|
|||
class HomeSeeAllNavigationTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun personalMovieContinuationReachesTheEighteenthTitleAndReturnsToHome() {
|
||||
@Test fun personalMovieContinuationReachesTheEighteenthTitleAndReturnsToHome() = continuation("pk-m", 5) { grid ->
|
||||
assertEquals(18, grid.items.size)
|
||||
repeat(2) { press(Key.DirectionDown) }
|
||||
repeat(5) { press(Key.DirectionRight) }
|
||||
rule.onNodeWithText("Bleach the Movie: Fade to Black").assertIsDisplayed()
|
||||
assertEquals("movie-bleach-the-movie-fade-to-black-2008", grid.focusedTitle?.id)
|
||||
}
|
||||
|
||||
@Test fun horizontallyScrolledContinuationRestoresTheExactHomeViewport() = continuation("trending", 20) { grid ->
|
||||
press(Key.DirectionDown)
|
||||
assertEquals(6, grid.index)
|
||||
rule.onNodeWithTag("library-grid").assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun continuation(rowId: String, expectedColumn: Int, exerciseGrid: (LibraryGridState) -> Unit) {
|
||||
rule.mainClock.autoAdvance = false
|
||||
val snapshot = HomeFixture(rule.activity).snapshot("justin")
|
||||
val row = snapshot.rows.first { it.id == "pk-m" }
|
||||
val row = snapshot.rows.first { it.id == rowId }
|
||||
val cardTag = "home-card:$rowId:$expectedColumn"
|
||||
val navigation = NavigationManager()
|
||||
val lists = WolfskinSeeAllLists()
|
||||
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin") {
|
||||
|
|
@ -58,24 +75,56 @@ class HomeSeeAllNavigationTest {
|
|||
else WolfskinLibraryContent(rule.activity, route, shell, user, "fixture", facade, navigation, lists, modifier)
|
||||
} }
|
||||
}
|
||||
rule.onNodeWithTag("home-card:pk-m:5").assertIsDisplayed()
|
||||
settle()
|
||||
rule.onNodeWithTag(cardTag).assertIsDisplayed()
|
||||
val home = shell.view
|
||||
val track = home.home.tracks.getValue(rowId)
|
||||
val verticalIndex = home.listState.firstVisibleItemIndex
|
||||
val verticalOffset = home.listState.firstVisibleItemScrollOffset
|
||||
val rowOffset = home.listState.layoutInfo.visibleItemsInfo.first { it.key == rowId }.offset
|
||||
val horizontalIndex = track.firstVisibleItemIndex
|
||||
val horizontalOffset = track.firstVisibleItemScrollOffset
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals("seeall/justin/pk-m", (navigation.backStack.last() as Destination.Wolfskin).route)
|
||||
val grid = requireNotNull(lists.get("justin/pk-m"))
|
||||
assertEquals(18, grid.items.size)
|
||||
repeat(2) { press(Key.DirectionDown) }
|
||||
repeat(5) { press(Key.DirectionRight) }
|
||||
rule.onNodeWithText("Bleach the Movie: Fade to Black").assertIsDisplayed()
|
||||
assertEquals("movie-bleach-the-movie-fade-to-black-2008", grid.focusedTitle?.id)
|
||||
press(Key.Back)
|
||||
rule.waitForIdle()
|
||||
assertTrue(navigation.backStack.last() is Destination.Home)
|
||||
assertEquals(row.id, snapshot.rows[shell.view.row].id)
|
||||
assertEquals(5, shell.view.cards[row.id])
|
||||
rule.onNodeWithTag("home-card:pk-m:5").assertIsDisplayed()
|
||||
assertEquals("seeall/justin/$rowId", (navigation.backStack.last() as Destination.Wolfskin).route)
|
||||
val grid = requireNotNull(lists.get("justin/$rowId"))
|
||||
try {
|
||||
rule.onNodeWithTag("library-grid").assertIsDisplayed()
|
||||
exerciseGrid(grid)
|
||||
press(Key.Back)
|
||||
rule.waitForIdle()
|
||||
assertTrue(navigation.backStack.last() is Destination.Home)
|
||||
assertEquals(row.id, snapshot.rows[shell.view.row].id)
|
||||
assertEquals(expectedColumn, shell.view.cards[row.id])
|
||||
assertSame("Home retains its original route memory", home, shell.view)
|
||||
assertSame("Home retains its original horizontal state", track, home.home.tracks.getValue(rowId))
|
||||
assertEquals("Vertical first item", verticalIndex, home.listState.firstVisibleItemIndex)
|
||||
assertEquals("Vertical pixel offset", verticalOffset, home.listState.firstVisibleItemScrollOffset)
|
||||
assertEquals("Selected row pixel offset", rowOffset, home.listState.layoutInfo.visibleItemsInfo.first { it.key == rowId }.offset)
|
||||
assertEquals("Horizontal first item", horizontalIndex, track.firstVisibleItemIndex)
|
||||
assertEquals("Horizontal pixel offset", horizontalOffset, track.firstVisibleItemScrollOffset)
|
||||
rule.onNodeWithTag(cardTag).assertIsDisplayed()
|
||||
} catch (failure: AssertionError) {
|
||||
throw AssertionError(
|
||||
"Expected $rowId:$expectedColumn vertical=$verticalIndex/$verticalOffset row-offset=$rowOffset horizontal=$horizontalIndex/$horizontalOffset; " +
|
||||
"actual route=${shell.route} row=${home.row} card=${home.cards[rowId]} nav=${shell.navFocused} handler=${shell.contentFocus?.javaClass?.simpleName} grid=${grid.index} " +
|
||||
"vertical=${home.listState.firstVisibleItemIndex}/${home.listState.firstVisibleItemScrollOffset} horizontal=${track.firstVisibleItemIndex}/${track.firstVisibleItemScrollOffset} " +
|
||||
"visible-rows=${home.listState.layoutInfo.visibleItemsInfo.map { it.key to it.offset }}",
|
||||
failure,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun press(key: Key) {
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
|
||||
settle()
|
||||
}
|
||||
|
||||
private fun settle() {
|
||||
repeat(3) {
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
rule.waitForIdle()
|
||||
}
|
||||
rule.mainClock.advanceTimeBy(1_000)
|
||||
rule.waitForIdle()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ fun WolfskinLibraryContent(
|
|||
seeAll: WolfskinSeeAllLists,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val view = shell.view
|
||||
val view = shell.viewFor(route)
|
||||
val fixture by produceState<LibraryFixture?>(null, context) {
|
||||
if (BuildConfig.DEBUG) value = withContext(Dispatchers.IO) {
|
||||
context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") }
|
||||
|
|
@ -89,7 +89,7 @@ fun WolfskinLibraryContent(
|
|||
if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false
|
||||
}
|
||||
}
|
||||
LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null)
|
||||
LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null, view = view)
|
||||
}
|
||||
|
||||
val LIBRARY_DESTINATIONS = setOf("movies", "shows", "anime")
|
||||
|
|
|
|||
|
|
@ -131,10 +131,16 @@ the complete underlying list rather than the visible shelf window. Title content
|
|||
remains the later-slice target.
|
||||
|
||||
All Home components and immutable screen models live in `:wolfskin-design`.
|
||||
The shell's remembered view owns vertical position, each row's card and first
|
||||
visible card, and the bounded six-hour ratings session cache. Back preserves the viewport; returning
|
||||
to Home does not re-run focus-follow over a manually scrolled saved view. The
|
||||
next arrow re-enters visible content when scrolling moved focus off-screen.
|
||||
The shell's remembered view owns vertical position, each row's selected card and
|
||||
actual horizontal `LazyListState`, and the bounded six-hour ratings session cache.
|
||||
Composing destinations bind `viewFor(route)` before navigation effects run, so a
|
||||
new See all grid cannot borrow or clamp Home's scroller. Re-entry restores the
|
||||
remembered track before the page with `scrollToItem`, preserving exact indices and
|
||||
pixel offsets rather than animating back. Android focus stays with the shell;
|
||||
cards use its logical focus, without requests against uncomposed lazy items.
|
||||
Back preserves the viewport; returning to Home does not re-run focus-follow over
|
||||
a manually scrolled saved view. The next arrow re-enters visible content when
|
||||
scrolling moved focus off-screen.
|
||||
Pointing changes focus without activation, cards activate on key-up, and held
|
||||
OK events cannot activate twice.
|
||||
Between shelves, an unvisited row selects the nearest visible card center even
|
||||
|
|
|
|||
|
|
@ -8,18 +8,19 @@ class HomeFocus(
|
|||
private val rows: List<HomeRow>,
|
||||
private val onAction: (HomeAction) -> Unit,
|
||||
) : ShellContentFocus {
|
||||
private val view = shell.viewFor("home")
|
||||
override val cardKey: String? get() = row?.let { if (it.kind == HomeRowKind.Shelf) it.cards.getOrNull(column)?.id ?: it.id else null }
|
||||
val row: HomeRow? get() = rows.getOrNull(shell.view.row)
|
||||
val column: Int get() = row?.let { shell.view.cards[it.id] ?: 0 } ?: 0
|
||||
val row: HomeRow? get() = rows.getOrNull(view.row)
|
||||
val column: Int get() = row?.let { view.cards[it.id] ?: 0 } ?: 0
|
||||
|
||||
fun focus(index: Int, column: Int) {
|
||||
shell.navFocused = false
|
||||
shell.view.row = index
|
||||
shell.view.home.focusedKey = rows[index].id
|
||||
view.row = index
|
||||
view.home.focusedKey = rows[index].id
|
||||
val target = rows[index]
|
||||
val bounded = column.coerceIn(0, (target.count - 1).coerceAtLeast(0))
|
||||
shell.view.cards[target.id] = bounded
|
||||
if (target.kind == HomeRowKind.Shelf) shell.view.home.firstCards[target.id] = target.adjustedFirst(bounded, shell.view.home.firstCards[target.id] ?: 0)
|
||||
view.cards[target.id] = bounded
|
||||
if (target.kind == HomeRowKind.Shelf) view.home.firstCards[target.id] = target.adjustedFirst(bounded, view.home.firstCards[target.id] ?: 0)
|
||||
}
|
||||
|
||||
override fun key(key: ShellKey): Boolean {
|
||||
|
|
@ -28,19 +29,19 @@ class HomeFocus(
|
|||
if (key == ShellKey.Ok) { activate(current); return true }
|
||||
if (reenterVisible()) return true
|
||||
when (key) {
|
||||
ShellKey.Left -> focus(shell.view.row, (column - 1).coerceAtLeast(0))
|
||||
ShellKey.Right -> focus(shell.view.row, (column + 1).coerceAtMost((current.count - 1).coerceAtLeast(0)))
|
||||
ShellKey.Left -> focus(view.row, (column - 1).coerceAtLeast(0))
|
||||
ShellKey.Right -> focus(view.row, (column + 1).coerceAtMost((current.count - 1).coerceAtLeast(0)))
|
||||
ShellKey.Up, ShellKey.Down -> {
|
||||
val direction = if (key == ShellKey.Up) -1 else 1
|
||||
var next = shell.view.row + direction
|
||||
var next = view.row + direction
|
||||
while (next in rows.indices && !rows[next].focusable) next += direction
|
||||
if (next in rows.indices) {
|
||||
val target = rows[next]
|
||||
val nearest = nearest(current, target)
|
||||
focus(next, shell.view.cards[target.id] ?: nearest)
|
||||
focus(next, view.cards[target.id] ?: nearest)
|
||||
} else if (direction < 0) {
|
||||
shell.view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0)
|
||||
shell.view.home.resetScroll++
|
||||
view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0)
|
||||
view.home.resetScroll++
|
||||
shell.navFocused = true
|
||||
shell.navIndex = shell.currentTab
|
||||
}
|
||||
|
|
@ -50,7 +51,6 @@ class HomeFocus(
|
|||
}
|
||||
|
||||
private fun reenterVisible(): Boolean {
|
||||
val view = shell.view
|
||||
val layout = view.listState.layoutInfo
|
||||
if (layout.totalItemsCount == 0 || view.home.positionedKey != row?.id || view.listState.isScrollInProgress) return false
|
||||
val density = layout.viewportSize.width / WolfskinTokens.Shell.stageWidth.value
|
||||
|
|
@ -79,9 +79,9 @@ class HomeFocus(
|
|||
|
||||
private fun nearest(from: HomeRow, to: HomeRow): Int {
|
||||
if (to.kind != HomeRowKind.Shelf) return (column * pitch(from) / pitch(to)).toInt()
|
||||
val fromFirst = if (from.kind == HomeRowKind.Shelf) shell.view.home.firstCards[from.id] ?: from.adjustedFirst(column, 0) else 0
|
||||
val fromFirst = if (from.kind == HomeRowKind.Shelf) view.home.firstCards[from.id] ?: from.adjustedFirst(column, 0) else 0
|
||||
val x = (column - fromFirst) * pitch(from) + from.cardWidth.value / 2
|
||||
val first = (shell.view.home.firstCards[to.id] ?: 0).coerceIn(0, (to.count - to.focusSlots).coerceAtLeast(0))
|
||||
val first = (view.home.firstCards[to.id] ?: 0).coerceIn(0, (to.count - to.focusSlots).coerceAtLeast(0))
|
||||
val last = minOf(to.count - 1, first + to.focusSlots - 1)
|
||||
var best = first
|
||||
var distance = Float.POSITIVE_INFINITY
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ fun HomeScreen(
|
|||
modifier: Modifier = Modifier,
|
||||
onAction: (HomeAction) -> Unit,
|
||||
) {
|
||||
val view = shell.view
|
||||
val view = shell.viewFor("home")
|
||||
LaunchedEffect(view, snapshot.savedAt) { view.savedAt = snapshot.savedAt }
|
||||
val action by rememberUpdatedState(onAction)
|
||||
val focus = remember(shell, snapshot.rows, view) { HomeFocus(shell, snapshot.rows) { action(it) } }
|
||||
|
|
@ -59,6 +59,14 @@ fun HomeScreen(
|
|||
LaunchedEffect(view.row, snapshot.rows) {
|
||||
snapshot.rows.drop(view.row + 1).firstOrNull { it.kind == HomeRowKind.Shelf && !it.skeleton }?.let { WolfskinImages.prefetch(context, it) }
|
||||
}
|
||||
LaunchedEffect(view) {
|
||||
if (view.home.positionedKey != null) {
|
||||
view.home.focusedKey?.let { view.home.tracks[it] }?.let { track ->
|
||||
track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset)
|
||||
}
|
||||
view.listState.scrollToItem(view.listState.firstVisibleItemIndex, view.listState.firstVisibleItemScrollOffset)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(view, view.row, view.home.resetScroll, snapshot.rows) {
|
||||
val key = snapshot.rows.getOrNull(view.row)?.id
|
||||
if (view.home.positionedKey == key && view.home.positionedReset == view.home.resetScroll) return@LaunchedEffect
|
||||
|
|
|
|||
|
|
@ -8,13 +8,17 @@ import androidx.compose.ui.platform.LocalDensity
|
|||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
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.SideEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
|
|
@ -38,11 +42,19 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
|
|||
val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero
|
||||
val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0)
|
||||
SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first }
|
||||
val track = rememberLazyListState(first)
|
||||
val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } }
|
||||
val targetFirst by rememberUpdatedState(first)
|
||||
val density = LocalDensity.current
|
||||
LaunchedEffect(first) {
|
||||
val pitch = with(density) { (width + K.posterGap).toPx() }
|
||||
track.animateScrollBy((first - track.firstVisibleItemIndex) * pitch - track.firstVisibleItemScrollOffset, tween(WolfskinTokens.Motion.base))
|
||||
LaunchedEffect(track) {
|
||||
track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset)
|
||||
var positionedFirst = targetFirst
|
||||
snapshotFlow { targetFirst }.collectLatest { target ->
|
||||
if (target != positionedFirst) {
|
||||
positionedFirst = target
|
||||
val pitch = with(density) { (width + K.posterGap).toPx() }
|
||||
track.animateScrollBy((target - track.firstVisibleItemIndex) * pitch - track.firstVisibleItemScrollOffset, tween(WolfskinTokens.Motion.base))
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) {
|
||||
if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
|
|
@ -9,6 +10,7 @@ import androidx.compose.runtime.setValue
|
|||
class HomeViewMemory {
|
||||
val ratings = HomeRatingCache()
|
||||
val firstCards = mutableStateMapOf<String, Int>()
|
||||
val tracks = mutableMapOf<String, LazyListState>()
|
||||
var loadingEntryRevision: Long? = null
|
||||
var focusedKey: String? = null
|
||||
var positionedKey: String? = null
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ fun LibraryScreen(
|
|||
cachedRatings: Set<String> = emptySet(),
|
||||
ratingsReachable: Boolean = true,
|
||||
offlineArt: ((String) -> ImageBitmap?)? = null,
|
||||
view: RememberedView = shell.view,
|
||||
) {
|
||||
val view = shell.view
|
||||
val density = LocalDensity.current
|
||||
val context = LocalContext.current
|
||||
val active = !shell.navFocused && !shell.pickerVisible
|
||||
|
|
|
|||
|
|
@ -68,7 +68,10 @@ class ShellState(
|
|||
private set
|
||||
val tabs: List<String> get() = if (person.hasAnime) ANIME_TABS else BASE_TABS
|
||||
val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0)
|
||||
val view: RememberedView get() = views.getOrPut(person.key to route) { RememberedView() }
|
||||
val view: RememberedView get() = viewFor(route)
|
||||
|
||||
/** A composing destination must not borrow the previous route's scroller before its enter effect. */
|
||||
fun viewFor(destination: String): RememberedView = views.getOrPut(person.key to destination) { RememberedView() }
|
||||
|
||||
/** External intents and upstream pops remain authoritative over the screen. */
|
||||
fun enterDestination(destination: String) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue