From 9cfe9362eeeb74c41bde93320b005469b0b6f11a Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 18:02:17 +0100 Subject: [PATCH] Fix picker access and finish Wolfskin on root Back --- .../wholphin/services/NavigationManager.kt | 11 +- .../wolfskin/tv/WolfskinBackStackTest.kt | 147 ++++++++++++++ .../wolfskin/tv/WolfskinRootExitTest.kt | 184 ++++++++++++++++++ .../justinvisser/wolfskin/tv/WolfskinApp.kt | 10 +- .../wolfskin/tv/WolfskinBackHandler.kt | 28 +++ data/tv-spec/spec.md | 4 +- docker/README.md | 22 ++- .../wolfskin/design/ShellState.kt | 16 +- .../wolfskin/design/WhosWatching.kt | 9 +- .../wolfskin/design/PickerNavigationTest.kt | 71 +++++++ .../wolfskin/design/ShellKeysTest.kt | 10 - 11 files changed, 481 insertions(+), 31 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinBackStackTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt create mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinBackHandler.kt create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerNavigationTest.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt index 87095f8b..ef211d7e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt @@ -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 = NavBackStack(Destination.Home()) + private val exits = Channel(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() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinBackStackTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinBackStackTest.kt new file mode 100644 index 00000000..31f71803 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinBackStackTest.kt @@ -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() + 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(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(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(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() + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt new file mode 100644 index 00000000..4e19a94a --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt @@ -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() + 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(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user)) + val libraries = mockk(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() + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index 2eb874eb..074f312e 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -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 diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinBackHandler.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinBackHandler.kt new file mode 100644 index 00000000..8fd409fc --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinBackHandler.kt @@ -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 +} diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index a67848fd..38f097ae 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -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 diff --git a/docker/README.md b/docker/README.md index 3ddcfda1..41e41061 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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. diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt index ecae94c8..b18715bd 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -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) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt index 224469bc..b140deb2 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt @@ -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)) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerNavigationTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerNavigationTest.kt new file mode 100644 index 00000000..35916d32 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerNavigationTest.kt @@ -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() + + @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() + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt index 77052d6f..7b032dce 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt @@ -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