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
|
|
@ -2,6 +2,8 @@ package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import androidx.navigation3.runtime.NavBackStack
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import org.acra.ACRA
|
import org.acra.ACRA
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -15,6 +17,11 @@ class NavigationManager
|
||||||
@Inject
|
@Inject
|
||||||
constructor() {
|
constructor() {
|
||||||
var backStack: MutableList<Destination> = NavBackStack(Destination.Home())
|
var backStack: MutableList<Destination> = NavBackStack(Destination.Home())
|
||||||
|
private val exits = Channel<Unit>(Channel.CONFLATED)
|
||||||
|
val exitRequests = exits.receiveAsFlow()
|
||||||
|
|
||||||
|
/** Finish the containing task; never expose an older setup/user entry. */
|
||||||
|
fun requestExit() { exits.trySend(Unit) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination]
|
* Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination]
|
||||||
|
|
@ -41,12 +48,14 @@ class NavigationManager
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Go to the previous page
|
* Go to the previous page, or exit when there is no parent.
|
||||||
*/
|
*/
|
||||||
fun goBack() {
|
fun goBack() {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
if (backStack.size > 1) {
|
if (backStack.size > 1) {
|
||||||
backStack.removeLastOrNull()
|
backStack.removeLastOrNull()
|
||||||
|
} else {
|
||||||
|
requestExit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log()
|
log()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.snapshots.Snapshot
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.semantics.SemanticsActions
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrRatingsService
|
||||||
|
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService
|
||||||
|
import com.github.damontecres.wholphin.ui.main.settings.HomePageSettingsState
|
||||||
|
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage
|
||||||
|
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsViewModel
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage
|
||||||
|
import com.github.damontecres.wholphin.ui.setup.InstallUpdateState
|
||||||
|
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.Shadows
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/** Real full-screen pages keep their local Back ladder before task-level exit. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class WolfskinBackStackTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
private lateinit var activity: ComponentActivity
|
||||||
|
private val navigation = NavigationManager()
|
||||||
|
private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
|
||||||
|
private val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin") {
|
||||||
|
when (it) {
|
||||||
|
ShellAction.Pop -> navigation.goBack()
|
||||||
|
ShellAction.Exit -> navigation.requestExit()
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}.apply { pickerVisible = false; pickerLaunch = false; navFocused = true }
|
||||||
|
|
||||||
|
@Before fun initialize() { activity = rule.activity }
|
||||||
|
|
||||||
|
@Test fun singletonUpdatesBackFinishesInsteadOfBecomingADeadEnd() {
|
||||||
|
navigation.replace(Destination.UpdateApp)
|
||||||
|
val model = updatesModel()
|
||||||
|
rule.setContent {
|
||||||
|
rememberWolfskinBackHandler(shell, navigation)
|
||||||
|
WolfskinTheme { InstallUpdatePage(preferences, viewModel = model) }
|
||||||
|
}
|
||||||
|
rule.onNodeWithText("No release found! Check the update URL.").assertIsDisplayed()
|
||||||
|
back()
|
||||||
|
assertTrue(activity.isFinishing)
|
||||||
|
assertEquals(listOf(Destination.UpdateApp), navigation.backStack.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun pushedUpdatesPopsSettingsThenPickerBeforeRootExit() {
|
||||||
|
navigation.navigateTo(Destination.Wolfskin("settings"))
|
||||||
|
navigation.navigateTo(Destination.UpdateApp)
|
||||||
|
shell.enterDestination("settings")
|
||||||
|
shell.navFocused = false
|
||||||
|
val model = updatesModel()
|
||||||
|
rule.setContent {
|
||||||
|
rememberWolfskinBackHandler(shell, navigation)
|
||||||
|
WolfskinTheme {
|
||||||
|
if (navigation.backStack.last() == Destination.UpdateApp) InstallUpdatePage(preferences, viewModel = model)
|
||||||
|
else ShellSurface(shell) { SettingsList(shell, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rule.onNodeWithText("No release found! Check the update URL.").assertIsDisplayed()
|
||||||
|
back()
|
||||||
|
assertFalse(activity.isFinishing)
|
||||||
|
assertEquals(listOf(Destination.Home(), Destination.Wolfskin("settings")), navigation.backStack.toList())
|
||||||
|
back()
|
||||||
|
assertFalse(activity.isFinishing)
|
||||||
|
assertTrue(shell.pickerVisible)
|
||||||
|
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
||||||
|
back()
|
||||||
|
assertFalse(shell.pickerVisible)
|
||||||
|
assertTrue(shell.navFocused)
|
||||||
|
assertFalse(activity.isFinishing)
|
||||||
|
back()
|
||||||
|
assertTrue(activity.isFinishing)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun unsavedHomeSettingsPromptsBeforeDiscardCanExitTheTask() {
|
||||||
|
navigation.replace(Destination.HomeSettings)
|
||||||
|
val model = mockk<HomeSettingsViewModel>(relaxed = true)
|
||||||
|
every { model.state } returns MutableStateFlow(HomePageSettingsState.EMPTY.copy(loading = LoadingState.Success))
|
||||||
|
every { model.serverPluginActive } returns MutableStateFlow(false)
|
||||||
|
every { model.settingsChanged } returns MutableStateFlow(true)
|
||||||
|
every { model.navigationManager } returns navigation
|
||||||
|
every { model.discardChanges() } returns Job().apply { complete() }
|
||||||
|
val images = mockk<ImageUrlService>(relaxed = true)
|
||||||
|
val ratings = SeerrRatingsService(mockk(relaxed = true), mockk(relaxed = true))
|
||||||
|
rule.setContent {
|
||||||
|
rememberWolfskinBackHandler(shell, navigation)
|
||||||
|
WolfskinTheme {
|
||||||
|
CompositionLocalProvider(LocalSeerrRatingsService provides ratings, LocalImageUrlService provides images) {
|
||||||
|
HomeSettingsPage(preferences, Modifier.fillMaxSize(), viewModel = model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
back()
|
||||||
|
assertFalse("Back must not discard unsaved settings or finish the task", activity.isFinishing)
|
||||||
|
assertEquals(listOf(Destination.HomeSettings), navigation.backStack.toList())
|
||||||
|
val discard = rule.onNodeWithText("Discard").assertIsDisplayed().assertIsEnabled()
|
||||||
|
discard.performSemanticsAction(SemanticsActions.RequestFocus) { it() }
|
||||||
|
discard.assertIsFocused().performKeyInput { pressKey(Key.DirectionCenter) }
|
||||||
|
rule.waitUntil(10_000) {
|
||||||
|
Snapshot.sendApplyNotifications()
|
||||||
|
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
activity.isFinishing
|
||||||
|
}
|
||||||
|
assertEquals(listOf(Destination.HomeSettings), navigation.backStack.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updatesModel(): UpdateViewModel = mockk<UpdateViewModel>(relaxed = true).also { model ->
|
||||||
|
every { model.state } returns MutableStateFlow(InstallUpdateState(loading = LoadingState.Success))
|
||||||
|
every { model.navigationManager } returns navigation
|
||||||
|
every { model.updater.hasPermissions() } returns true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun back() {
|
||||||
|
rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() }
|
||||||
|
rule.mainClock.advanceTimeBy(400)
|
||||||
|
if (!activity.isFinishing) rule.waitForIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.runtime.snapshots.Snapshot
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.Shadows
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/** Actual Wolfskin NavDisplay and Android Back finish the activity instead of exposing upstream setup. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class WolfskinRootExitTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
private lateinit var activity: ComponentActivity
|
||||||
|
private lateinit var model: WolfskinShellViewModel
|
||||||
|
private lateinit var conductarr: ConductarrSession
|
||||||
|
private val navigation = NavigationManager()
|
||||||
|
private val session = WolfskinSession()
|
||||||
|
private var oldFixtures = false
|
||||||
|
private var oldScreens = true
|
||||||
|
|
||||||
|
@Before fun initialize() {
|
||||||
|
activity = rule.activity
|
||||||
|
ConductarrPreference.initialize(activity)
|
||||||
|
WolfskinScreenPreference.initialize(activity)
|
||||||
|
oldFixtures = ConductarrPreference.fixtures
|
||||||
|
oldScreens = WolfskinScreenPreference.enabled
|
||||||
|
ConductarrPreference.updateFixtures(true)
|
||||||
|
WolfskinScreenPreference.enabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun cleanup() {
|
||||||
|
if (::model.isInitialized) model.viewModelScope.cancel()
|
||||||
|
if (::conductarr.isInitialized) conductarr.close()
|
||||||
|
ConductarrPreference.updateFixtures(oldFixtures)
|
||||||
|
WolfskinScreenPreference.enabled = oldScreens
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun homeContentThenNavBackFinishesTheActivity() = verifyRoot("home")
|
||||||
|
@Test fun discoverContentThenNavBackFinishesTheActivity() = verifyRoot("discover")
|
||||||
|
@Test fun moviesContentThenNavBackFinishesTheActivity() = verifyRoot("movies")
|
||||||
|
@Test fun showsContentThenNavBackFinishesTheActivity() = verifyRoot("shows")
|
||||||
|
@Test fun animeContentThenNavBackFinishesTheActivity() = verifyRoot("anime")
|
||||||
|
|
||||||
|
@Test fun hardwareBackUsesTheSameContentNavExitLadder() {
|
||||||
|
mount()
|
||||||
|
val shell = session.state!!
|
||||||
|
rule.runOnIdle { shell.pickerVisible = false; shell.pickerLaunch = false }
|
||||||
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back) }
|
||||||
|
assertTrue(shell.navFocused)
|
||||||
|
assertFalse(activity.isFinishing)
|
||||||
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back) }
|
||||||
|
rule.mainClock.advanceTimeBy(400)
|
||||||
|
assertTrue(activity.isFinishing)
|
||||||
|
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun coldPickerBackFinishesWithoutSelectingAnAvatarOrShowingUpstreamUi() {
|
||||||
|
mount()
|
||||||
|
assertTrue(session.state!!.pickerLaunch)
|
||||||
|
back()
|
||||||
|
assertTrue("Back from the cold picker must finish the activity", activity.isFinishing)
|
||||||
|
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun reopenedPickerBackDismissesToNavAndTheFollowingBackExits() {
|
||||||
|
mount()
|
||||||
|
val shell = session.state!!
|
||||||
|
rule.runOnIdle { shell.pickerVisible = false; shell.focusNav(shell.tabs.size); shell.key(ShellKey.Ok) }
|
||||||
|
assertTrue(shell.pickerVisible)
|
||||||
|
back()
|
||||||
|
assertFalse(shell.pickerVisible)
|
||||||
|
assertTrue(shell.navFocused)
|
||||||
|
assertFalse(activity.isFinishing)
|
||||||
|
back()
|
||||||
|
assertTrue(activity.isFinishing)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun comingInSheetDismissesBeforeContentNavAndExit() {
|
||||||
|
mount()
|
||||||
|
val shell = session.state!!
|
||||||
|
rule.runOnIdle {
|
||||||
|
shell.pickerVisible = false
|
||||||
|
shell.pickerLaunch = false
|
||||||
|
shell.navFocused = false
|
||||||
|
shell.openRequests()
|
||||||
|
}
|
||||||
|
assertTrue(shell.requestsVisible)
|
||||||
|
back()
|
||||||
|
assertFalse(shell.requestsVisible)
|
||||||
|
assertFalse(shell.navFocused)
|
||||||
|
assertFalse(activity.isFinishing)
|
||||||
|
back()
|
||||||
|
assertTrue(shell.navFocused)
|
||||||
|
assertFalse(activity.isFinishing)
|
||||||
|
back()
|
||||||
|
assertTrue(activity.isFinishing)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verifyRoot(route: String) {
|
||||||
|
mount()
|
||||||
|
val shell = session.state!!
|
||||||
|
val destination = if (route == "home") Destination.Home() else Destination.Wolfskin(route)
|
||||||
|
rule.runOnIdle {
|
||||||
|
shell.pickerVisible = false
|
||||||
|
shell.pickerLaunch = false
|
||||||
|
shell.enterDestination(route)
|
||||||
|
navigation.replace(destination)
|
||||||
|
}
|
||||||
|
rule.mainClock.advanceTimeBy(1200)
|
||||||
|
rule.waitForIdle()
|
||||||
|
rule.waitUntil(10_000) {
|
||||||
|
Snapshot.sendApplyNotifications()
|
||||||
|
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
shell.contentFocus?.hasContent == true &&
|
||||||
|
(route !in LIBRARY_DESTINATIONS || shell.rememberedView(route).library?.status == LibraryStatus.Ready)
|
||||||
|
}
|
||||||
|
rule.runOnIdle { shell.navFocused = false }
|
||||||
|
back()
|
||||||
|
assertFalse("The first Back must not finish $route content", activity.isFinishing)
|
||||||
|
assertTrue("The first Back must move $route content to nav", shell.navFocused)
|
||||||
|
assertEquals(listOf(destination), navigation.backStack.toList())
|
||||||
|
back()
|
||||||
|
assertTrue("Back from $route nav must finish the activity", activity.isFinishing)
|
||||||
|
assertEquals("Exit cannot detour to old Home", listOf(destination), navigation.backStack.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mount() {
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null)
|
||||||
|
val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = null)
|
||||||
|
val repository = mockk<ServerRepository>(relaxed = true)
|
||||||
|
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user))
|
||||||
|
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||||
|
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
|
||||||
|
conductarr = ConductarrSession(MemoryCacheStore())
|
||||||
|
model = WolfskinShellViewModel(activity, session, repository, navigation, mockk(relaxed = true),
|
||||||
|
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||||
|
val home = HomeFixture(activity).snapshot("justin")
|
||||||
|
rule.setContent {
|
||||||
|
WolfskinApp(server, user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model,
|
||||||
|
homeContent = { shell, _, _, modifier -> HomeScreen(shell, home, modifier) {} })
|
||||||
|
}
|
||||||
|
rule.waitUntil(10_000) {
|
||||||
|
Snapshot.sendApplyNotifications()
|
||||||
|
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
session.state?.people?.size == 3 && model.signedInPerson == "justin"
|
||||||
|
}
|
||||||
|
rule.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun back() {
|
||||||
|
rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() }
|
||||||
|
rule.mainClock.advanceTimeBy(400)
|
||||||
|
if (!activity.isFinishing) rule.waitForIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package org.justinvisser.wolfskin.tv
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -201,6 +200,7 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ShellAction.Pop -> navigation.goBack()
|
ShellAction.Pop -> navigation.goBack()
|
||||||
|
ShellAction.Exit -> navigation.requestExit()
|
||||||
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
|
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
|
||||||
is ShellAction.Person -> selectPerson(action.person, server)
|
is ShellAction.Person -> selectPerson(action.person, server)
|
||||||
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
|
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
|
||||||
|
|
@ -271,7 +271,9 @@ fun WolfskinApp(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val loading = remember(user.id) {
|
val loading = remember(user.id) {
|
||||||
ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()).apply { peopleStatus = PeopleStatus.Loading }
|
ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()) {
|
||||||
|
if (it == ShellAction.Exit) navigationManager.requestExit()
|
||||||
|
}.apply { peopleStatus = PeopleStatus.Loading }
|
||||||
}
|
}
|
||||||
val state = viewModel.session.state ?: loading
|
val state = viewModel.session.state ?: loading
|
||||||
val discover = rememberDiscoverState(
|
val discover = rememberDiscoverState(
|
||||||
|
|
@ -285,6 +287,7 @@ fun WolfskinApp(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
RequestsRefresh(state, viewModel::refreshRequests)
|
RequestsRefresh(state, viewModel::refreshRequests)
|
||||||
|
val onBack = rememberWolfskinBackHandler(state, navigationManager)
|
||||||
WolfskinTheme {
|
WolfskinTheme {
|
||||||
CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover,
|
CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover,
|
||||||
org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) {
|
org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) {
|
||||||
|
|
@ -300,7 +303,7 @@ fun WolfskinApp(
|
||||||
}
|
}
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
backStack = navigationManager.backStack,
|
backStack = navigationManager.backStack,
|
||||||
onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) },
|
onBack = onBack,
|
||||||
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
|
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
|
||||||
entryProvider = { destination ->
|
entryProvider = { destination ->
|
||||||
NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
|
NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
|
||||||
|
|
@ -321,7 +324,6 @@ fun WolfskinApp(
|
||||||
if (destination.fullScreen) {
|
if (destination.fullScreen) {
|
||||||
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
|
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
|
||||||
} else {
|
} else {
|
||||||
BackHandler(enabled = isCurrent) { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
|
|
||||||
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
|
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
|
||||||
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
|
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
|
||||||
val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null
|
val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.ui.findActivity
|
||||||
|
import org.justinvisser.wolfskin.design.ShellKey
|
||||||
|
import org.justinvisser.wolfskin.design.ShellState
|
||||||
|
|
||||||
|
/** Own root Back even when NavDisplay has no parent; child menus/dialogs retain first refusal. */
|
||||||
|
@Composable
|
||||||
|
internal fun rememberWolfskinBackHandler(shell: ShellState, navigation: NavigationManager): () -> Unit {
|
||||||
|
val activity = checkNotNull(LocalContext.current.findActivity()) { "Wolfskin root requires an Activity" }
|
||||||
|
LaunchedEffect(navigation, activity) {
|
||||||
|
navigation.exitRequests.collect { activity.finishAndRemoveTask() }
|
||||||
|
}
|
||||||
|
val onBack: () -> Unit = remember(shell, navigation) {
|
||||||
|
{
|
||||||
|
if (navigation.backStack.lastOrNull()?.fullScreen == true) navigation.goBack()
|
||||||
|
else shell.key(ShellKey.Back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
return onBack
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,9 @@
|
||||||
|
|
||||||
## Who’s watching
|
## Who’s watching
|
||||||
|
|
||||||
The Settings gear is horizontally centred beneath the complete people row, independent of person count or selection. Justin confirmed this placement. It uses the same 4 px accent straddle ring as the people, with the existing 1.08 scale and focus-only Settings caption. Down still reaches it only from the final person; Up returns to that person.
|
Round-2 correction: the Settings gear is centred beneath the **last avatar**, independent of selection. Down from **any avatar** reaches it; Up returns to the avatar that supplied focus, including after visiting Settings and returning. Left/Right still traverse only avatars. This supersedes the revision-5 whole-row centring and final-avatar-only Down rule. The 4 px accent straddle ring, 1.08 scale and focus-only Settings caption are unchanged.
|
||||||
|
|
||||||
|
Root content Back moves to the nav; the next Back **finishes and removes Wolfskin’s Android task**, returning to the launcher without visiting an old Home/setup interface. This applies to Home, Discover, Movies, Shows and Anime. Cold-launch picker Back exits; a reopened picker first dismisses to its opener’s nav. Pushed title/person/settings/Updates pages pop their parent stack, and menus/sheets/unsaved-settings prompts get first refusal. A singleton full-screen page exits rather than becoming a dead end.
|
||||||
|
|
||||||
## Focus visibility
|
## Focus visibility
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -227,7 +227,9 @@ The stroke is inset within the fixed 20 dp / 40 px height; horizontal border-box
|
||||||
padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width
|
padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width
|
||||||
ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%.
|
ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%.
|
||||||
|
|
||||||
The small gear centred beneath the complete people row opens Settings with Requests focused.
|
The small gear beneath the last avatar opens Settings with Requests focused.
|
||||||
|
Down from any avatar reaches it; Up returns to the originating avatar, including
|
||||||
|
after a Settings visit. Left/Right avatar traversal is unchanged.
|
||||||
Settings groups hand off to existing upstream pages; Requests opens the household
|
Settings groups hand off to existing upstream pages; Requests opens the household
|
||||||
Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent
|
Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent
|
||||||
rollout switch: turning it off renders upstream Home and library destinations with
|
rollout switch: turning it off renders upstream Home and library destinations with
|
||||||
|
|
@ -236,6 +238,20 @@ Settings list. Native Up traversal stays in content until the first focusable ro
|
||||||
then enters the nav. Turning the switch on renders Wolfskin Home and Libraries.
|
then enters the nav. Turning the switch on renders Wolfskin Home and Libraries.
|
||||||
Discover and Requests are integrated Wolfskin surfaces; title hand-offs remain labelled.
|
Discover and Requests are integrated Wolfskin surfaces; title hand-offs remain labelled.
|
||||||
|
|
||||||
|
Root content Back focuses the nav; another Back exits the Android task via
|
||||||
|
`finishAndRemoveTask`, including Discover, Movies, Shows and Anime without a Home
|
||||||
|
detour. Cold picker Back exits; a reopened picker dismisses to nav first. Pushed
|
||||||
|
pages pop normally. Child dialogs, title menus, request sheets and unsaved-setting
|
||||||
|
prompts keep Back priority. Singleton full-screen pages also exit; they cannot
|
||||||
|
fall through to the older setup/user UI.
|
||||||
|
|
||||||
|
`rememberWolfskinBackHandler` owns Android Back at the app root, including a
|
||||||
|
single-entry `NavDisplay`; full-screen hand-offs use the same `NavigationManager`
|
||||||
|
exit event rather than exposing the parent setup stack. `WolfskinRootExitTest`
|
||||||
|
mounts actual Wolfskin Home/Discover/library surfaces and covers picker,
|
||||||
|
hardware Back and Coming-in sheet precedence. `WolfskinBackStackTest` mounts
|
||||||
|
the real Updates and Home Settings pages to cover parent pops and unsaved edits.
|
||||||
|
|
||||||
Saved Jellyfin identities use the existing upstream session. Conductarr supplies
|
Saved Jellyfin identities use the existing upstream session. Conductarr supplies
|
||||||
household labels, requester capabilities and library audiences after sign-in.
|
household labels, requester capabilities and library audiences after sign-in.
|
||||||
The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used
|
The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used
|
||||||
|
|
@ -655,7 +671,9 @@ The [revision-5 amendment](../data/tv-spec/spec.md) and
|
||||||
[native frame prototype delta](../data/tv-v2/feedback-2026-11-06/index.html)
|
[native frame prototype delta](../data/tv-v2/feedback-2026-11-06/index.html)
|
||||||
retain the approved frames except for the requested feedback changes:
|
retain the approved frames except for the requested feedback changes:
|
||||||
|
|
||||||
- The picker gear is centred under everyone and uses the picker-person ring.
|
- Round 2 supersedes revision 5: the picker gear sits under the last avatar,
|
||||||
|
is reachable with Down from every avatar, and returns Up to its origin.
|
||||||
|
It retains the picker-person ring.
|
||||||
- Shelf tracks share second-last-slot lookahead, count clamping and settled
|
- Shelf tracks share second-last-slot lookahead, count clamping and settled
|
||||||
offsets. Home records completed frame positioning rather than an animation
|
offsets. Home records completed frame positioning rather than an animation
|
||||||
that may still be cancelled; first mount restores the intended shelf item.
|
that may still be cancelled; first mount restores the intended shelf item.
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ sealed interface ShellAction {
|
||||||
data class Person(val person: ShellPerson) : ShellAction
|
data class Person(val person: ShellPerson) : ShellAction
|
||||||
data class HandOff(val group: String) : ShellAction
|
data class HandOff(val group: String) : ShellAction
|
||||||
data object Pop : ShellAction
|
data object Pop : ShellAction
|
||||||
|
data object Exit : ShellAction
|
||||||
data object RetryPeople : ShellAction
|
data object RetryPeople : ShellAction
|
||||||
data object RetryRequests : ShellAction
|
data object RetryRequests : ShellAction
|
||||||
data class RequestTitle(val request: ComingInRequest) : ShellAction
|
data class RequestTitle(val request: ComingInRequest) : ShellAction
|
||||||
|
|
@ -196,7 +197,7 @@ class ShellState(
|
||||||
when (key) {
|
when (key) {
|
||||||
ShellKey.Left -> pickerIndex = (pickerIndex - 1).coerceAtLeast(0)
|
ShellKey.Left -> pickerIndex = (pickerIndex - 1).coerceAtLeast(0)
|
||||||
ShellKey.Right -> pickerIndex = (pickerIndex + 1).coerceAtMost(people.lastIndex)
|
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.Ok -> choosePerson(people[pickerIndex])
|
||||||
ShellKey.Back -> closePicker()
|
ShellKey.Back -> closePicker()
|
||||||
else -> Unit
|
else -> Unit
|
||||||
|
|
@ -220,7 +221,8 @@ class ShellState(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun closePicker() {
|
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) {
|
private fun navKey(key: ShellKey) {
|
||||||
|
|
@ -288,7 +290,7 @@ class ShellState(
|
||||||
onAction(ShellAction.RequestTitle(request))
|
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() {
|
fun back() {
|
||||||
if (requestsVisible) { closeRequests(); return }
|
if (requestsVisible) { closeRequests(); return }
|
||||||
val returnRoute = requestReturnRoute
|
val returnRoute = requestReturnRoute
|
||||||
|
|
@ -306,17 +308,13 @@ class ShellState(
|
||||||
pickerVisible = true
|
pickerVisible = true
|
||||||
pickerLaunch = false
|
pickerLaunch = false
|
||||||
gearFocused = true
|
gearFocused = true
|
||||||
pickerIndex = people.lastIndex
|
|
||||||
} else if (route !in tabs) {
|
} else if (route !in tabs) {
|
||||||
onAction(ShellAction.Pop)
|
onAction(ShellAction.Pop)
|
||||||
} else if (!navFocused) {
|
} else if (!navFocused) {
|
||||||
navFocused = true
|
navFocused = true
|
||||||
navIndex = currentTab
|
navIndex = currentTab
|
||||||
} else if (route != "home") {
|
} else {
|
||||||
route = "home"
|
onAction(ShellAction.Exit)
|
||||||
rootRoute = "home"
|
|
||||||
navIndex = 0
|
|
||||||
onAction(ShellAction.Destination(route))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
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
|
@Composable
|
||||||
fun WhosWatching(state: ShellState, modifier: Modifier = Modifier) {
|
fun WhosWatching(state: ShellState, modifier: Modifier = Modifier) {
|
||||||
Box(modifier.fillMaxSize().background(C.bg)) {
|
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)) {
|
Row(Modifier.fillMaxWidth().offset(y = S.pickerPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) {
|
||||||
state.people.forEachIndexed { index, person ->
|
state.people.forEachIndexed { index, person ->
|
||||||
val focused = !state.gearFocused && state.pickerIndex == index
|
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)
|
state.focusPerson(index); state.key(ShellKey.Ok)
|
||||||
}, horizontalAlignment = Alignment.CenterHorizontally) {
|
}, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker,
|
Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker,
|
||||||
|
|
@ -52,8 +53,8 @@ private fun PeoplePicker(state: ShellState) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val left = (S.stageWidth - L.gear) / 2
|
val left = (S.stageWidth - L.gear + (L.pickerAvatar + L.pickerGap) * (state.people.size - 1)) / 2
|
||||||
Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard)
|
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)
|
.focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing)
|
||||||
.clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) {
|
.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))
|
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)
|
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() {
|
@Test fun rootBackAndNavDownPreserveViewAndWrap() {
|
||||||
replay(listOf(Key.DirectionCenter), 1)
|
replay(listOf(Key.DirectionCenter), 1)
|
||||||
val view = state.view
|
val view = state.view
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue