Fix picker access and finish Wolfskin on root Back
This commit is contained in:
parent
febcded034
commit
9cfe9362ee
11 changed files with 481 additions and 31 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue