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 com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import org.acra.ACRA
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
|
@ -15,6 +17,11 @@ class NavigationManager
|
|||
@Inject
|
||||
constructor() {
|
||||
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]
|
||||
|
|
@ -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() {
|
||||
synchronized(this) {
|
||||
if (backStack.size > 1) {
|
||||
backStack.removeLastOrNull()
|
||||
} else {
|
||||
requestExit()
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -201,6 +200,7 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
ShellAction.Pop -> navigation.goBack()
|
||||
ShellAction.Exit -> navigation.requestExit()
|
||||
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
|
||||
is ShellAction.Person -> selectPerson(action.person, server)
|
||||
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
|
||||
|
|
@ -271,7 +271,9 @@ fun WolfskinApp(
|
|||
}
|
||||
}
|
||||
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 discover = rememberDiscoverState(
|
||||
|
|
@ -285,6 +287,7 @@ fun WolfskinApp(
|
|||
},
|
||||
)
|
||||
RequestsRefresh(state, viewModel::refreshRequests)
|
||||
val onBack = rememberWolfskinBackHandler(state, navigationManager)
|
||||
WolfskinTheme {
|
||||
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) {
|
||||
|
|
@ -300,7 +303,7 @@ fun WolfskinApp(
|
|||
}
|
||||
NavDisplay(
|
||||
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()),
|
||||
entryProvider = { destination ->
|
||||
NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
|
||||
|
|
@ -321,7 +324,6 @@ fun WolfskinApp(
|
|||
if (destination.fullScreen) {
|
||||
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
|
||||
} else {
|
||||
BackHandler(enabled = isCurrent) { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
|
||||
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
|
||||
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent
|
||||
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.
|
||||
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
|
||||
household labels, requester capabilities and library audiences after sign-in.
|
||||
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)
|
||||
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
|
||||
offsets. Home records completed frame positioning rather than an animation
|
||||
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 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