Round 2 release candidate: Home, Discover, trailer, crash fix and stock screens #64
7 changed files with 82 additions and 32 deletions
|
|
@ -16,6 +16,7 @@ import androidx.compose.ui.input.key.type
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.justinvisser.wolfskin.design.DiscoverOverlay
|
||||
import org.justinvisser.wolfskin.design.ShellKey
|
||||
import org.justinvisser.wolfskin.design.isScreenEntryActive
|
||||
import org.justinvisser.wolfskin.design.readShellKey
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
|
@ -339,7 +340,7 @@ fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferen
|
|||
if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false
|
||||
}
|
||||
val person = viewModel.person.takeIf { title != null }
|
||||
BackHandler(enabled = title != null) {
|
||||
BackHandler(enabled = isScreenEntryActive() && title != null) {
|
||||
if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell)
|
||||
}
|
||||
Box(modifier.onPreviewKeyEvent { event ->
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ The lane retains the `cw` identity. Legacy local, server, web and plugin Home se
|
|||
|
||||
Every navigation entry owns its own viewport even while an outgoing page and incoming page coexist during push/Back. Only the active entry may acquire Android focus or register the shell's logical key controller. An outgoing title/person page must never rebind to Home's `LazyListState` when the shared route changes. Down skips non-focusable/pending rows, reaches the final real row, and scrolls it into view after Back and refresh.
|
||||
|
||||
An unvisited Home row starts at its leftmost card rather than inheriting horizontal alignment from the previous row. Visited rows retain their remembered column and first-visible position across row changes, title Back and root-tab leave/return. Home does not adopt Discover’s tab-leave memory reset; Library keeps standard grid navigation. Outgoing title/person entries cannot intercept Android Back during their exit animation.
|
||||
|
||||
Landscape-card bottom gradients extend through the reserved state-line area, ending at the solid bottom backing with no exposed artwork band. The title-gradient top padding is **36 px / 18 dp**, down from 54 px / 27 dp; the approved 92% bottom opacity is unchanged. Captions and state lines retain their existing geometry.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -392,9 +392,12 @@ 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
|
||||
when card widths differ. A visited row restores its column. Horizontal movement
|
||||
keeps the track still while focus remains in its visible middle.
|
||||
Between Home shelves, an unvisited row starts at its leftmost card. A visited row
|
||||
restores its column and first-visible position, including after leaving and
|
||||
returning to the Home tab; Home memory is not reset on tab leave. Library keeps
|
||||
standard grid navigation. Horizontal movement keeps the track still while focus
|
||||
remains in its visible middle. Title/person Android Back handlers use the same
|
||||
active-entry ownership flag, so outgoing pages cannot consume a second Back.
|
||||
The initial skeleton parks focus on the nav. When the first content arrives,
|
||||
an untouched fresh entry focuses hero button zero; intervening nav input prevents
|
||||
that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus`
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
/** Home navigation mutates the shell's per-person view, including each row's remembered column. */
|
||||
class HomeFocus(
|
||||
private val shell: ShellState,
|
||||
|
|
@ -38,8 +36,7 @@ class HomeFocus(
|
|||
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, view.cards[target.id] ?: nearest)
|
||||
focus(next, view.cards[target.id] ?: 0)
|
||||
} else if (direction < 0) {
|
||||
view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0)
|
||||
view.home.resetScroll++
|
||||
|
|
@ -77,27 +74,6 @@ class HomeFocus(
|
|||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
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) view.home.firstCards[from.id] ?: from.adjustedFirst(column, 0) else 0
|
||||
val x = (column - fromFirst) * pitch(from) + from.cardWidth.value / 2
|
||||
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
|
||||
for (index in first..last) {
|
||||
val candidate = abs((index - first) * pitch(to) + to.cardWidth.value / 2 - x)
|
||||
if (candidate < distance) { best = index; distance = candidate }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
private fun pitch(row: HomeRow): Float = when (row.cardKind) {
|
||||
HomeCardKind.Poster -> WolfskinTokens.Card.posterPitch.value
|
||||
HomeCardKind.Tile -> WolfskinTokens.Card.tilePitch.value
|
||||
HomeCardKind.Wide -> WolfskinTokens.Card.widePitch.value
|
||||
}
|
||||
}
|
||||
|
||||
internal val HomeRow.focusSlots: Int get() = when (cardKind) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import androidx.compose.runtime.remember
|
|||
internal val LocalScreenView = compositionLocalOf<RememberedView?> { null }
|
||||
internal val LocalScreenActive = compositionLocalOf { true }
|
||||
|
||||
/** Outgoing entries cannot consume Back while their exit animation remains composed. */
|
||||
@Composable
|
||||
fun isScreenEntryActive(): Boolean = LocalScreenActive.current
|
||||
|
||||
/** Overlapping navigation entries retain their own viewport; only the current entry owns input. */
|
||||
@Composable
|
||||
fun ScreenEntry(shell: ShellState, route: String, active: Boolean, content: @Composable () -> Unit) {
|
||||
|
|
|
|||
|
|
@ -105,13 +105,15 @@ class HomeKeysTest {
|
|||
assertNull(action)
|
||||
}
|
||||
|
||||
@Test fun downClampsToShortRowsAndNeverMovesPastTheFinalSeeAll() {
|
||||
@Test fun rememberedColumnsClampToShortRowsAndTheFinalSeeAll() {
|
||||
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false }
|
||||
val cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards
|
||||
val long = HomeRow(id = "long", kind = HomeRowKind.Shelf, cards = cards)
|
||||
val short = long.copy(id = "short", cards = cards.take(2), total = 2)
|
||||
val tail = short.copy(id = "tail", total = 20)
|
||||
shell.view.cards[long.id] = cards.lastIndex
|
||||
shell.view.cards[short.id] = cards.lastIndex
|
||||
shell.view.cards[tail.id] = 2
|
||||
var action: HomeAction? = null
|
||||
rule.setContent { WolfskinTheme {
|
||||
ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(long, short, tail)), modifier) { action = it } }
|
||||
|
|
@ -132,17 +134,18 @@ class HomeKeysTest {
|
|||
assertEquals(tail.id, (action as HomeAction.SeeAll).row.id)
|
||||
}
|
||||
|
||||
@Test fun mixedWidthRowsUseNearestCenterAndKeepTheTrackWhenMovingBackInsideItsMiddle() {
|
||||
@Test fun visitedMixedWidthRowsKeepColumnsAndTrackWhenMovingInsideTheMiddle() {
|
||||
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false }
|
||||
val cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards
|
||||
val posters = HomeRow(id = "posters", kind = HomeRowKind.Shelf, cards = cards)
|
||||
val tiles = posters.copy(id = "tiles", cardKind = HomeCardKind.Tile)
|
||||
shell.view.cards[posters.id] = 3
|
||||
shell.view.cards[tiles.id] = 2
|
||||
rule.setContent { WolfskinTheme {
|
||||
ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(posters, tiles)), modifier) {} }
|
||||
} }
|
||||
press("DOWN")
|
||||
assertEquals("The third tile is nearest the fourth poster's horizontal center", 2, shell.view.cards[tiles.id])
|
||||
assertEquals(2, shell.view.cards[tiles.id])
|
||||
repeat(4) { press("RIGHT") }
|
||||
val left = rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left
|
||||
press("LEFT")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Home starts new rows at zero and retains visited positions independently of other root tabs. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
class HomeRowEntryTest {
|
||||
private val people = listOf(ShellPerson("justin", "Justin", "J", true))
|
||||
private val cards = List(12) { HomeTitle("item-$it", "Film $it", "movie") }
|
||||
|
||||
@Test fun unvisitedRowsStartLeftmostAcrossCardWidths() {
|
||||
for (from in HomeCardKind.entries) for (to in HomeCardKind.entries) {
|
||||
val shell = ShellState(people, "justin").apply { pickerVisible = false; navFocused = false }
|
||||
val source = HomeRow("source", HomeRowKind.Shelf, cards = cards, cardKind = from)
|
||||
val target = source.copy(id = "target", cardKind = to)
|
||||
val focus = HomeFocus(shell, listOf(source, target)) {}
|
||||
focus.focus(0, 3)
|
||||
focus.key(ShellKey.Down)
|
||||
assertEquals("$from to $to", target.id, focus.row?.id)
|
||||
assertEquals("$from to $to must start leftmost", 0, focus.column)
|
||||
assertEquals(0, shell.rememberedView("home").home.firstCards[target.id])
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun visitedColumnsAndFirstVisiblePositionsSurviveRowChangesAndRootTabLeave() {
|
||||
val shell = ShellState(people, "justin").apply { pickerVisible = false; navFocused = false }
|
||||
val source = HomeRow("source", HomeRowKind.Shelf, cards = cards)
|
||||
val rows = listOf(source, source.copy(id = "target", cardKind = HomeCardKind.Tile))
|
||||
val focus = HomeFocus(shell, rows) {}
|
||||
focus.focus(0, 8)
|
||||
focus.key(ShellKey.Left)
|
||||
val view = shell.rememberedView("home")
|
||||
val first = view.home.firstCards.getValue(source.id)
|
||||
val track = LazyListState(first, 19)
|
||||
view.home.tracks[source.id] = track
|
||||
focus.key(ShellKey.Down)
|
||||
focus.key(ShellKey.Up)
|
||||
assertEquals(7, focus.column)
|
||||
assertEquals(first, view.home.firstCards[source.id])
|
||||
|
||||
shell.focusNav(shell.tabs.indexOf("discover")); shell.key(ShellKey.Ok)
|
||||
shell.focusNav(shell.tabs.indexOf("home")); shell.key(ShellKey.Ok)
|
||||
val restored = HomeFocus(shell, rows) {}
|
||||
assertSame(view, shell.rememberedView("home"))
|
||||
assertEquals(7, restored.column)
|
||||
restored.key(ShellKey.Down)
|
||||
restored.key(ShellKey.Up)
|
||||
assertEquals(7, restored.column)
|
||||
assertEquals(first, view.home.firstCards[source.id])
|
||||
assertSame(track, view.home.tracks[source.id])
|
||||
assertEquals(first, track.firstVisibleItemIndex)
|
||||
assertEquals(19, track.firstVisibleItemScrollOffset)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue