Fix picker access and finish Wolfskin on root Back

This commit is contained in:
justinzeus 2026-11-07 18:02:17 +01:00
parent febcded034
commit 9cfe9362ee
11 changed files with 481 additions and 31 deletions

View file

@ -15,6 +15,7 @@ sealed interface ShellAction {
data class Person(val person: ShellPerson) : ShellAction
data class HandOff(val group: String) : ShellAction
data object Pop : ShellAction
data object Exit : ShellAction
data object RetryPeople : ShellAction
data object RetryRequests : ShellAction
data class RequestTitle(val request: ComingInRequest) : ShellAction
@ -196,7 +197,7 @@ class ShellState(
when (key) {
ShellKey.Left -> pickerIndex = (pickerIndex - 1).coerceAtLeast(0)
ShellKey.Right -> pickerIndex = (pickerIndex + 1).coerceAtMost(people.lastIndex)
ShellKey.Down -> if (pickerIndex == people.lastIndex) gearFocused = true
ShellKey.Down -> gearFocused = true
ShellKey.Ok -> choosePerson(people[pickerIndex])
ShellKey.Back -> closePicker()
else -> Unit
@ -220,7 +221,8 @@ class ShellState(
}
private fun closePicker() {
if (!pickerLaunch) { pickerVisible = false; navFocused = true; navIndex = tabs.size }
if (pickerLaunch) onAction(ShellAction.Exit)
else { pickerVisible = false; navFocused = true; navIndex = tabs.size }
}
private fun navKey(key: ShellKey) {
@ -288,7 +290,7 @@ class ShellState(
onAction(ShellAction.RequestTitle(request))
}
/** Back never discards a root view, including the card below a focused nav. */
/** Back retains root view memory; a second root Back exits instead of detouring through Home. */
fun back() {
if (requestsVisible) { closeRequests(); return }
val returnRoute = requestReturnRoute
@ -306,17 +308,13 @@ class ShellState(
pickerVisible = true
pickerLaunch = false
gearFocused = true
pickerIndex = people.lastIndex
} else if (route !in tabs) {
onAction(ShellAction.Pop)
} else if (!navFocused) {
navFocused = true
navIndex = currentTab
} else if (route != "home") {
route = "home"
rootRoute = "home"
navIndex = 0
onAction(ShellAction.Destination(route))
} else {
onAction(ShellAction.Exit)
}
}

View file

@ -20,12 +20,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
/** The picker covers the nav and exposes Settings only below the final person. */
/** The picker covers the nav; Down from any person reaches Settings below the final avatar. */
@Composable
fun WhosWatching(state: ShellState, modifier: Modifier = Modifier) {
Box(modifier.fillMaxSize().background(C.bg)) {
@ -42,7 +43,7 @@ private fun PeoplePicker(state: ShellState) {
Row(Modifier.fillMaxWidth().offset(y = S.pickerPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) {
state.people.forEachIndexed { index, person ->
val focused = !state.gearFocused && state.pickerIndex == index
Column(Modifier.width(L.pickerAvatar).focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable {
Column(Modifier.width(L.pickerAvatar).testTag("picker-person:${person.key}").focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable {
state.focusPerson(index); state.key(ShellKey.Ok)
}, horizontalAlignment = Alignment.CenterHorizontally) {
Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker,
@ -52,8 +53,8 @@ private fun PeoplePicker(state: ShellState) {
}
}
}
val left = (S.stageWidth - L.gear) / 2
Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard)
val left = (S.stageWidth - L.gear + (L.pickerAvatar + L.pickerGap) * (state.people.size - 1)) / 2
Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).testTag("picker-settings").focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard)
.focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing)
.clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) {
Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon))

View file

@ -0,0 +1,71 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
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
import org.robolectric.annotation.GraphicsMode
/** Every profile reaches Settings vertically without losing its horizontal return position. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class PickerNavigationTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun downFromEveryAvatarReachesSettingsAndUpReturnsToTheOrigin() {
val state = ShellState(fixturePeople(), "justin")
rule.setContent { WolfskinTheme { ShellSurface(state) } }
for (index in state.people.indices) {
rule.runOnIdle { state.pickerIndex = index; state.gearFocused = false }
press(Key.DirectionDown)
assertTrue("Down from profile $index must reach Settings", state.gearFocused)
press(Key.DirectionUp)
assertFalse(state.gearFocused)
assertEquals("Up must return to profile $index", index, state.pickerIndex)
}
}
@Test fun settingsBackThenUpKeepsTheAvatarThatOpenedTheGear() {
val state = ShellState(fixturePeople(), "justin")
rule.setContent { WolfskinTheme { ShellSurface(state) } }
for (index in state.people.indices) {
rule.runOnIdle { state.pickerIndex = index; state.gearFocused = false }
press(Key.DirectionDown)
press(Key.DirectionCenter)
assertEquals("settings", state.route)
press(Key.Back)
assertTrue(state.pickerVisible)
assertTrue(state.gearFocused)
press(Key.DirectionUp)
assertEquals(index, state.pickerIndex)
}
}
@Test fun settingsGearIsCenteredUnderTheFinalAvatarForAnyProfileCount() {
val state = ShellState(fixturePeople(), "justin")
rule.setContent { WolfskinTheme { ShellSurface(state) } }
for (count in 1..5) {
val people = (0 until count).map { ShellPerson("person-$it", "Person $it", "$it", false) }
rule.runOnIdle { state.updatePeople(people); state.gearFocused = true }
rule.mainClock.advanceTimeBy(400)
val last = rule.onNodeWithTag("picker-person:${people.last().key}").fetchSemanticsNode().boundsInRoot
val gear = rule.onNodeWithTag("picker-settings").fetchSemanticsNode().boundsInRoot
assertEquals("Settings must stay under the last profile for $count people", last.center.x, gear.center.x, .1f)
assertTrue("Settings must be below the avatars", gear.top > last.bottom)
}
}
private fun press(key: Key) {
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
rule.mainClock.advanceTimeBy(400)
rule.waitForIdle()
}
}

View file

@ -60,16 +60,6 @@ class ShellKeysTest {
assertEquals(2, state.settingsRow)
}
@Test fun onlyLastPersonReachesGearAndUpReturnsThere() {
replay(listOf(Key.DirectionDown, Key.DirectionRight, Key.DirectionDown), 3)
assertFalse(state.gearFocused)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionDown) }
assertTrue(state.gearFocused)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionUp) }
assertFalse(state.gearFocused)
assertEquals(2, state.pickerIndex)
}
@Test fun rootBackAndNavDownPreserveViewAndWrap() {
replay(listOf(Key.DirectionCenter), 1)
val view = state.view