diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt index 35e45892..ad3d4647 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt @@ -88,7 +88,7 @@ class AnimeAvailabilityTest { } } val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { requireNotNull(model.onForeground(server, user)).join() assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt index 1af82b5c..125a67bd 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt @@ -69,7 +69,7 @@ class AnimeRootRemovalTest { val navigation = NavigationManager() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { model.load(server, user) requireNotNull(model.onForeground(server, user)).join() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt index b748367e..052aa05c 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt @@ -49,7 +49,7 @@ class ConductarrAudienceRecoveryTest { val client = session.identity.value!!.client val libraries = LibraryReadFacade(mockk()) val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { requireNotNull(model.onForeground(server, user)).join() assertNull(libraries.audiences) @@ -82,7 +82,7 @@ class ConductarrAudienceRecoveryTest { session.authenticate() val libraries = LibraryReadFacade(mockk()) val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { val identity = session.identity.value!! val intent = org.justinvisser.wolfskin.design.DiscoverRequest( diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt index 07cd1e37..c412265e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt @@ -91,7 +91,7 @@ class FirstSignInNavigationTest { val navigation = NavigationManager() val session = WolfskinSession() val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true), - LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf( HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume"))) try { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt index 95fcbb52..35c5cb57 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt @@ -85,7 +85,7 @@ class PersonPickerSessionTest { val navigation = remember { NavigationManager() } val model = remember { WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)).also { models.add(it) } + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) } } DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } } WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt index 6a5936f8..9a1ff11e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt @@ -87,7 +87,7 @@ class PersonSwitchThreadingTest { coEvery { libraries.readDestinations(any()) } returns emptyMap() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) val clients = mutableListOf() try { model.load(server, justin) @@ -163,7 +163,7 @@ class PersonSwitchThreadingTest { coEvery { libraries.readDestinations(any()) } returns emptyMap() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { model.load(server, justin) requireNotNull(model.onForeground(server, justin)).join() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt index 1fb29d31..01a2e425 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt @@ -63,7 +63,7 @@ class SavedPeoplePickerTest { val session = WolfskinSession() val navigation = NavigationManager() val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), - mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { rule.setContent { WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt index dbcffdb4..8a01861d 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt @@ -66,7 +66,7 @@ class ShellStartupTimingTest { coEvery { libraries.readDestinations(user.id) } returns emptyMap() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) val load = launch { model.load(server, user) } try { withTimeout(5_000) { savedPeopleStarted.await() } @@ -121,7 +121,7 @@ class ShellStartupTimingTest { coEvery { libraries.readDestinations(any()) } returns emptyMap() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) val pending = CompletableDeferred() val release = java.util.concurrent.CountDownLatch(1) val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/UpdateBehaviorTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/UpdateBehaviorTest.kt new file mode 100644 index 00000000..72c07539 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/UpdateBehaviorTest.kt @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import org.junit.Assert.* +import org.junit.Test +import org.justinvisser.wolfskin.design.ShellKey +import org.justinvisser.wolfskin.tv.update.* + +/** Behavior boundaries are controller outcomes, not incidental UI wording. */ +class UpdateBehaviorTest { + @Test fun everyOutcomeHasOnlyExplicitUserActions() { + val expected = listOf( + UpdateUiState.Idle to listOf(UpdateCommand.Check), + UpdateUiState.Checking to listOf(UpdateCommand.Back), + UpdateUiState.UpToDate to listOf(UpdateCommand.Check), + UpdateUiState.NoReleasePublished to listOf(UpdateCommand.Check), + updateReleaseFixture() to listOf(UpdateCommand.Download, UpdateCommand.Notes), + UpdateUiState.Downloading(12, 30) to listOf(UpdateCommand.Cancel), + UpdateUiState.Ready("1.1.1") to listOf(UpdateCommand.Install, UpdateCommand.Notes), + UpdateUiState.PermissionNeeded to listOf(UpdateCommand.Install, UpdateCommand.Cancel), + UpdateUiState.HandingOff to listOf(UpdateCommand.Cancel), + UpdateUiState.InstallCancelled to listOf(UpdateCommand.Install, UpdateCommand.Notes), + UpdateUiState.Offline to listOf(UpdateCommand.Retry), + UpdateUiState.NoInstaller to listOf(UpdateCommand.Retry, UpdateCommand.Classic), + ) + UpdateFailure.entries.map { UpdateUiState.Failed(it, "operation failed") to listOf(UpdateCommand.Retry, UpdateCommand.Back) } + expected.forEach { (state, commands) -> + assertEquals(state.toString(), commands, presentUpdate(state, updateReleaseFixture()).actions.map { it.command }) + } + assertNotEquals(presentUpdate(UpdateUiState.UpToDate, null), presentUpdate(UpdateUiState.NoReleasePublished, null)) + } + + @Test fun percentageRequiresPositiveTotalAndIsBounded() { + listOf(null, 0L, -1L).forEach { total -> + val ui = presentUpdate(UpdateUiState.Downloading(20_100_000, total), updateReleaseFixture()) + assertNull(ui.fraction) + assertTrue(ui.title.isEmpty()) + assertNotNull(ui.byteProgress) + } + assertEquals(.42f, presentUpdate(UpdateUiState.Downloading(20_076_000, 47_800_000), null).fraction!!, .0001f) + assertEquals(1f, presentUpdate(UpdateUiState.Downloading(90, 30), null).fraction!!, 0f) + } + + @Test fun actionsAndAutoCheckRememberFocusWithoutTogglingOnMovement() { + val controller = FakeUpdateController(updateReleaseFixture()) + val focus = UpdateFocus(controller, {}, {}, {}, {}) + focus.update(controller.state.value, presentUpdate(controller.state.value, controller.release.value).actions) + focus.key(ShellKey.Right) + focus.key(ShellKey.Down) + assertTrue(focus.autoFocused) + assertTrue(controller.autoCheck.value) + focus.key(ShellKey.Ok) + assertFalse(controller.autoCheck.value) + focus.key(ShellKey.Up) + assertFalse(focus.autoFocused) + assertEquals(1, focus.actionIndex) + assertFalse(controller.autoCheck.value) + } + + @Test fun notesTrapFocusAndBackRestoresNotesAction() { + val controller = FakeUpdateController(updateReleaseFixture()) + var backs = 0 + var navs = 0 + val steps = mutableListOf() + val focus = UpdateFocus(controller, { backs++ }, {}, { navs++ }, steps::add) + focus.update(controller.state.value, presentUpdate(controller.state.value, controller.release.value).actions) + focus.key(ShellKey.Right); focus.key(ShellKey.Ok) + listOf(ShellKey.Up, ShellKey.Down, ShellKey.Left, ShellKey.Right, ShellKey.Ok).forEach(focus::key) + assertTrue(focus.notesOpen) + assertEquals(listOf(-1, 1), steps) + assertEquals(0, navs) + assertTrue(controller.autoCheck.value) + focus.key(ShellKey.Back) + assertFalse(focus.notesOpen) + assertEquals(1, focus.actionIndex) + assertEquals(0, backs) + focus.key(ShellKey.Up) + assertEquals(1, navs) + focus.key(ShellKey.Back) + assertEquals(1, backs) + } + + @Test fun progressUpdatesCannotStealFocusAndInstallerCancellationRestoresInstall() { + val controller = FakeUpdateController(UpdateUiState.Downloading(1, 100)) + val focus = UpdateFocus(controller, {}, {}, {}, {}) + fun update(state: UpdateUiState) { focus.update(state, presentUpdate(state, null).actions) } + update(controller.state.value) + focus.key(ShellKey.Down) + update(UpdateUiState.Downloading(90, 100)) + assertTrue(focus.autoFocused) + update(UpdateUiState.Ready("1.1.1")) + assertFalse(focus.autoFocused) + focus.key(ShellKey.Right) + update(UpdateUiState.PermissionNeeded) + update(UpdateUiState.InstallCancelled) + assertEquals(0, focus.actionIndex) + assertFalse(focus.autoFocused) + } +} + +/** Same release facts as the approved television frames. */ +fun updateReleaseFixture() = UpdateUiState.Available("1.1.1", 47_800_000, "6 Nov 2026", + "Updates inside Wolfskin settings.\nKeep sign-ins and preferences.\nClassic settings remains for a trial.") diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/UpdatesCaptureTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/UpdatesCaptureTest.kt new file mode 100644 index 00000000..e6643fd7 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/UpdatesCaptureTest.kt @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.SemanticsProperties +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +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.justinvisser.wolfskin.tv.update.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Native TV frames use the real shell, updater controller seam and Android key adapter. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class UpdatesCaptureTest { + @get:Rule val rule = createAndroidComposeRule() + private val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply { + pickerVisible = false + enterDestination("updates") + settingsRow = 1 + } + private lateinit var controller: FakeUpdateController + private val reduced = mutableStateOf(false) + private val directory get() = System.getProperty("wolfskin.updater.captureDirectory")?.let(::File) + + @Before fun fullscreen() { + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + rule.mainClock.autoAdvance = false + } + + private fun launch(state: UpdateUiState, auto: Boolean = true, settings: Boolean = false) { + controller = FakeUpdateController(state, auto, InstalledVersion("1.1.0-140-g5162c299", 100000), state as? UpdateUiState.Available ?: updateReleaseFixture()) + if (settings) shell.enterDestination("settings") + rule.setContent { + CompositionLocalProvider(LocalReducedMotion provides reduced.value) { + WolfskinTheme { + ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), + settingsInstalledVersion = controller.installedVersion.value.versionName) { modifier -> + UpdatesScreen(shell, controller, { shell.navFocused = false; shell.enterDestination("settings") }, {}, modifier) + } + } + } + } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + } + + private fun capture(name: String) { + val target = directory ?: return + target.mkdirs() + rule.waitForIdle() + val file = File(target, "$name.png") + file.parentFile?.mkdirs() + rule.onRoot().captureRoboImage(file.absolutePath) + } + + private fun press(vararg keys: Key) { + keys.forEach { key -> rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } } + rule.mainClock.advanceTimeBy(320) + rule.waitForIdle() + } + + @Test fun settings() { launch(UpdateUiState.Checking, settings = true); capture("settings") } + @Test fun classic() { + launch(UpdateUiState.Checking, settings = true) + repeat(8) { press(Key.DirectionDown) } + assertEquals(9, shell.settingsRow) + assertEquals(4, shell.view.listState.firstVisibleItemIndex) + capture("classic") + } + @Test fun checking() { launch(UpdateUiState.Checking); capture("checking") } + @Test fun current() { launch(UpdateUiState.UpToDate); capture("current") } + @Test fun available() { launch(updateReleaseFixture()); capture("available") } + @Test fun notes() { launch(updateReleaseFixture()); press(Key.DirectionRight); capture("notes") } + @Test fun notesOpen() { launch(updateReleaseFixture()); press(Key.DirectionRight, Key.DirectionCenter); capture("notes-open") } + @Test fun downloading() { launch(UpdateUiState.Downloading(20_100_000, 47_800_000)); capture("downloading") } + @Test fun ready() { launch(UpdateUiState.Ready("1.1.1")); capture("ready") } + @Test fun permission() { launch(UpdateUiState.PermissionNeeded); capture("permission") } + @Test fun handoff() { launch(UpdateUiState.HandingOff); capture("handoff") } + @Test fun cancelled() { launch(UpdateUiState.InstallCancelled); capture("cancelled") } + @Test fun failed() { launch(UpdateUiState.Failed(UpdateFailure.HttpRefused, "The download server refused the request (403).")); capture("failed") } + @Test fun offline() { launch(UpdateUiState.Offline); capture("offline") } + @Test fun noInstaller() { launch(UpdateUiState.NoInstaller); capture("no-installer") } + @Test fun noReleasePublished() { launch(UpdateUiState.NoReleasePublished); capture("no-release") } + @Test fun unknownDownloadLength() { launch(UpdateUiState.Downloading(20_100_000, null)); capture("unknown-total") } + @Test fun autoOn() { launch(updateReleaseFixture()); press(Key.DirectionDown); capture("auto-on") } + @Test fun autoOff() { launch(updateReleaseFixture()); press(Key.DirectionDown, Key.DirectionCenter); capture("auto-off") } + @Test fun updated() { + launch(UpdateUiState.UpToDate) + rule.runOnIdle { controller.showInstalled(InstalledVersion("1.1.1", 100001)) } + rule.mainClock.advanceTimeBy(320) + capture("updated") + } + + @Test fun notesTrapRealKeysAndBackPreservesSettingsRowAndScroller() { + launch(updateReleaseFixture()) + press(Key.DirectionRight, Key.DirectionCenter, Key.DirectionUp, Key.DirectionRight, Key.DirectionDown) + assertTrue(shell.contentOverlayVisible) + assertFalse(shell.navFocused) + assertTrue(controller.autoCheck.value) + press(Key.Back) + assertFalse(shell.contentOverlayVisible) + assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex) + press(Key.Back) + assertEquals("settings", shell.route) + assertEquals(1, shell.settingsRow) + assertEquals(0, shell.view.listState.firstVisibleItemIndex) + } + + @Test fun longNotesScrollWithoutEscapingToNavOrAutoCheck() { + launch(updateReleaseFixture().copy(notes = (1..30).joinToString("\n") { "Release detail $it" })) + press(Key.DirectionRight, Key.DirectionCenter, Key.DirectionDown, Key.DirectionDown) + val range = rule.onNodeWithTag("update-notes").fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange] + assertTrue(range.value() > 0f) + assertFalse(shell.navFocused) + assertFalse((shell.contentFocus as UpdateFocus).autoFocused) + press(Key.Back) + assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex) + } + + @Test fun nativeProgressAndStateTransitionAtSixteenMillisecondFrames() { + launch(UpdateUiState.Checking) + val target = directory + if (target != null) { + repeat(113) { frame -> + rule.mainClock.advanceTimeBy(16) + capture("motion/checking-%03d".format(frame)) + } + } + rule.runOnIdle { controller.show(UpdateUiState.Downloading(20_100_000, 47_800_000)) } + rule.mainClock.advanceTimeByFrame() + repeat(24) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/state-%03d".format(frame)) } + assertEquals(12f, rule.onNodeWithTag("update-progress").fetchSemanticsNode().boundsInRoot.height, .01f) + if (target != null) { + repeat(101) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/download-%03d".format(frame)) } + } + press(Key.DirectionDown) + rule.runOnIdle { controller.show(UpdateUiState.Downloading(31_000_000, 47_800_000)) } + rule.mainClock.advanceTimeBy(16) + assertTrue((shell.contentFocus as UpdateFocus).autoFocused) + rule.runOnIdle { reduced.value = true } + rule.mainClock.advanceTimeBy(96) + capture("motion/reduced-000") + rule.mainClock.advanceTimeBy(1600) + capture("motion/reduced-100") + assertTrue((shell.contentFocus as UpdateFocus).autoFocused) + } +} 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 aed8ec7b..a1d5c5e7 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -80,6 +80,7 @@ class WolfskinShellViewModel @Inject constructor( val seeAll: WolfskinSeeAllLists, val conductarr: ConductarrSession, artwork: WolfskinArtwork, + val updates: org.justinvisser.wolfskin.tv.update.UpdateController, ) : ViewModel() { init { artwork.configure(context) } val backdropFlow: StateFlow = backdrop.backdropFlow @@ -195,7 +196,7 @@ class WolfskinShellViewModel @Inject constructor( is ShellAction.Destination -> { val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route) when { - action.route == "settings" -> navigation.navigateTo(target) + action.route == "settings" || action.route == "updates" -> navigation.navigateTo(target) action.preservePushed && navigation.backStack.size > 1 -> navigation.backStack[0] = target else -> navigation.replace(target) } @@ -262,6 +263,7 @@ fun WolfskinApp( { shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) }, ) { val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() + val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle() LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) } androidx.lifecycle.compose.LifecycleEventEffect(androidx.lifecycle.Lifecycle.Event.ON_RESUME) { viewModel.onForeground(server, user) } LaunchedEffect(identity?.signedIn, identity?.person) { @@ -329,11 +331,15 @@ fun WolfskinApp( } else TitleDestination(route, state, preferences) } else { ShellSurface(state, handlesContentKeys = upstream == null, + settingsInstalledVersion = installedVersion.versionName, overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier -> when { (upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier) upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier) destination is Destination.Home -> homeContent(state, server, user, contentModifier) + route == "updates" -> org.justinvisser.wolfskin.tv.update.UpdatesScreen(state, viewModel.updates, + onBack = { state.navFocused = false; navigationManager.goBack() }, + onClassic = { navigationManager.navigateTo(handOffDestination("classic")) }, modifier = contentModifier) route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") -> WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier) destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt index ee7982d9..f7913df2 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt @@ -13,6 +13,7 @@ interface UpdateController { fun check(manual: Boolean) fun download() + /** Cancel partial bytes, or dismiss permission/handoff while retaining a verified APK. */ fun cancel() fun install() fun retry() diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateFocus.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateFocus.kt new file mode 100644 index 00000000..67d22cd2 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateFocus.kt @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv.update + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import org.justinvisser.wolfskin.design.ShellContentFocus +import org.justinvisser.wolfskin.design.ShellKey + +/** Action focus survives progress emissions; release notes own every key until Back. */ +class UpdateFocus( + private val controller: UpdateController, + private val back: () -> Unit, + private val classic: () -> Unit, + private val nav: () -> Unit, + private val scrollNotes: (Int) -> Unit, +) : ShellContentFocus { + override val cardKey: String? = null + var actionIndex by mutableIntStateOf(0) + private set + var autoFocused by mutableStateOf(false) + private set + var notesOpen by mutableStateOf(false) + private set + private var actions = emptyList() + private var previousState: Class<*>? = null + + /** Only a changed operation may retire an action; progress cannot steal focus. */ + fun update(state: UpdateUiState, value: List) { + if (previousState != state.javaClass) { + actionIndex = 0 + notesOpen = false + if (state == UpdateUiState.InstallCancelled || state is UpdateUiState.Ready) autoFocused = false + } + previousState = state.javaClass + actions = value + actionIndex = actionIndex.coerceAtMost(actions.lastIndex.coerceAtLeast(0)) + } + + fun focusAction(index: Int) { actionIndex = index; autoFocused = false } + fun focusAuto() { autoFocused = true } + + fun activate(index: Int) { + focusAction(index) + when (actions[index].command) { + UpdateCommand.Back -> back() + UpdateCommand.Check -> controller.check(manual = true) + UpdateCommand.Download -> controller.download() + UpdateCommand.Notes -> notesOpen = true + UpdateCommand.Cancel -> controller.cancel() + UpdateCommand.Install -> controller.install() + UpdateCommand.Retry -> controller.retry() + UpdateCommand.Classic -> classic() + } + } + + fun toggleAuto() { autoFocused = true; controller.setAutoCheck(!controller.autoCheck.value) } + + override fun key(key: ShellKey): Boolean { + if (notesOpen) { + when (key) { + ShellKey.Back -> notesOpen = false + ShellKey.Up -> scrollNotes(-1) + ShellKey.Down -> scrollNotes(1) + else -> Unit + } + return true + } + when (key) { + ShellKey.Back -> back() + ShellKey.Up -> if (autoFocused) autoFocused = false else nav() + ShellKey.Down -> autoFocused = true + ShellKey.Left -> if (!autoFocused) actionIndex = (actionIndex - 1).coerceAtLeast(0) + ShellKey.Right -> if (!autoFocused) actionIndex = (actionIndex + 1).coerceAtMost(actions.lastIndex.coerceAtLeast(0)) + ShellKey.Ok -> if (autoFocused) toggleAuto() else if (actions.isNotEmpty()) activate(actionIndex) + } + return true + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatePresentation.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatePresentation.kt new file mode 100644 index 00000000..3bc8c628 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatePresentation.kt @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv.update + +import java.text.NumberFormat + +/** Screen commands remain explicit; no state transition implicitly installs an APK. */ +enum class UpdateCommand { Back, Check, Download, Notes, Cancel, Install, Retry, Classic } + +/** An action is a user command, not a focusable progress indicator. */ +data class UpdateAction(val label: String, val command: UpdateCommand, val primary: Boolean = false) + +/** Plain copy for the process controller's state; bytes and notes remain real controller data. */ +data class UpdatePresentation( + val title: String = "", + val summary: String = "", + val paragraphs: List = emptyList(), + val actions: List, + val progress: Boolean = false, + val fraction: Float? = null, + val byteProgress: String? = null, +) + +/** Decimal megabytes match release sizes; unknown totals never create a percentage. */ +fun formatUpdateBytes(bytes: Long): String = NumberFormat.getNumberInstance().apply { + minimumFractionDigits = 1 + maximumFractionDigits = 1 +}.format(bytes / 1_000_000.0) + " MB" + +/** Metadata uses the controller's locale-formatted release date. */ +fun updateMetadata(release: UpdateUiState.Available?): String = release?.let { + "${it.versionName} · ${formatUpdateBytes(it.bytes)} · ${it.dateText}" +}.orEmpty() + +/** Every controller outcome is distinct from a successful compatible version comparison. */ +fun presentUpdate(state: UpdateUiState, release: UpdateUiState.Available?, updatedVersion: String? = null): UpdatePresentation { + val back = UpdateAction("Back", UpdateCommand.Back) + val notes = UpdateAction("Release notes", UpdateCommand.Notes) + val retry = UpdateAction("Retry", UpdateCommand.Retry, true) + val metadata = updateMetadata(release) + return when (state) { + UpdateUiState.Idle -> UpdatePresentation(actions = listOf(UpdateAction("Check for updates", UpdateCommand.Check, true))) + UpdateUiState.Checking -> UpdatePresentation(actions = listOf(back), progress = true) + UpdateUiState.UpToDate -> UpdatePresentation( + title = updatedVersion?.let { "Updated to $it" } ?: "Up to date", + summary = if (updatedVersion != null) "Sign-ins and settings kept." else "No newer release for this TV.", + actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true)), + ) + UpdateUiState.NoReleasePublished -> UpdatePresentation("No release published", "No update has been published for this TV yet.", + actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true))) + is UpdateUiState.Available -> UpdatePresentation("Update available", updateMetadata(state), + state.notes.lineSequence().filter { it.isNotBlank() }.take(3).toList(), + listOf(UpdateAction("Download", UpdateCommand.Download, true), notes)) + is UpdateUiState.Downloading -> { + val total = state.bytesTotal?.takeIf { it > 0 } + val fraction = total?.let { (state.bytesDone.toDouble() / it).coerceIn(0.0, 1.0).toFloat() } + val bytes = total?.let { "${formatUpdateBytes(state.bytesDone).removeSuffix(" MB")} / ${formatUpdateBytes(it)}" } + ?: formatUpdateBytes(state.bytesDone) + UpdatePresentation(fraction?.let { "${(it * 100).toInt()}%" }.orEmpty(), metadata, + actions = listOf(UpdateAction("Cancel download", UpdateCommand.Cancel)), progress = true, fraction = fraction, byteProgress = bytes) + } + is UpdateUiState.Ready -> UpdatePresentation("Ready to install", metadata.ifEmpty { state.versionName }, + listOf("Verified · your sign-ins and settings stay."), listOf(UpdateAction("Install update", UpdateCommand.Install, true), notes)) + UpdateUiState.PermissionNeeded -> UpdatePresentation("Allow updates on this TV", "Enable Wolfskin in Install unknown apps, then return.", + actions = listOf(UpdateAction("Open TV settings", UpdateCommand.Install, true), UpdateAction("Not now", UpdateCommand.Cancel))) + UpdateUiState.HandingOff -> UpdatePresentation("Continue in Android", "Confirm the update on the system screen.", + listOf("Wolfskin closes during installation.", "Choose Open, or reopen Wolfskin from the launcher."), listOf(UpdateAction("Back", UpdateCommand.Cancel))) + UpdateUiState.InstallCancelled -> UpdatePresentation("Install cancelled", metadata, + listOf("Verified · your sign-ins and settings stay."), listOf(UpdateAction("Try install again", UpdateCommand.Install, true), notes)) + is UpdateUiState.Failed -> UpdatePresentation(failureTitle(state.kind), state.message, actions = listOf(retry, back)) + UpdateUiState.Offline -> UpdatePresentation("No network", "Reconnect this TV, then try again.", + actions = listOf(UpdateAction("Try again", UpdateCommand.Retry, true))) + UpdateUiState.NoInstaller -> UpdatePresentation("This TV cannot open an installer", "Android’s install confirmation is unavailable.", + listOf("The download is kept. Classic settings may offer the same system limitation."), + listOf(retry, UpdateAction("Classic settings (trial)", UpdateCommand.Classic))) + } +} + +private fun failureTitle(kind: UpdateFailure): String = when (kind) { + UpdateFailure.Network -> "Check failed" + UpdateFailure.HttpRefused, UpdateFailure.InsufficientStorage -> "Download failed" + UpdateFailure.InvalidMetadata, UpdateFailure.UnsupportedAbi -> "Update unavailable" + UpdateFailure.ChecksumMismatch, UpdateFailure.WrongPackage, UpdateFailure.WrongSigner, UpdateFailure.LowerVersionCode -> "Verification failed" +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateProgress.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateProgress.kt new file mode 100644 index 00000000..fc23963c --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateProgress.kt @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv.update + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import org.justinvisser.wolfskin.design.reducedMotion +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M +import org.justinvisser.wolfskin.design.WolfskinTokens.Update as U + +/** Draw-only bounded track: changing bytes never moves logical or Android focus. */ +@Composable +fun UpdateProgress(fraction: Float?, modifier: Modifier = Modifier) { + val reduced = reducedMotion() + val phase = if (reduced) 0f else { + val transition = rememberInfiniteTransition(label = "update-progress") + val animated by transition.animateFloat(0f, 1f, + infiniteRepeatable(tween(if (fraction == null) M.updateScan else M.updateSheen, + easing = if (fraction == null) M.breatheEase else M.linear), RepeatMode.Restart), label = "highlight") + animated + } + Canvas(modifier.fillMaxWidth().height(U.progressHeight).testTag("update-progress") + .semantics { progressBarRangeInfo = fraction?.let { ProgressBarRangeInfo(it, 0f..1f) } ?: ProgressBarRangeInfo.Indeterminate }) { + val radius = CornerRadius(U.progressRadius.toPx()) + drawRoundRect(C.surface3, cornerRadius = radius) + val width = size.width * (fraction ?: U.scanFraction) + val left = if (fraction != null || reduced) 0f else width * (-1f + (U.scanEnd + 1f) * phase) + clipRect { + drawRoundRect(C.accent, Offset(left, 0f), Size(width, size.height), radius) + if (!reduced && fraction != null && width > 0f) { + clipRect(0f, 0f, width, size.height) { + val start = width * (-1f + 2f * phase) + drawRect(Brush.horizontalGradient(listOf(Color.Transparent, Color.White.copy(alpha = U.sheenAlpha), Color.Transparent), + startX = start, endX = start + width), size = Size(width, size.height)) + } + } + } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatesScreen.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatesScreen.kt new file mode 100644 index 00000000..ea69c6f7 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatesScreen.kt @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv.update + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.tv.material3.Text +import kotlinx.coroutines.launch +import org.justinvisser.wolfskin.design.Avatar +import org.justinvisser.wolfskin.design.CssColumn +import org.justinvisser.wolfskin.design.PickerButton +import org.justinvisser.wolfskin.design.ShellState +import org.justinvisser.wolfskin.design.WolfskinTokens +import org.justinvisser.wolfskin.design.exactLineBox +import org.justinvisser.wolfskin.design.focusRing +import org.justinvisser.wolfskin.design.focusScale +import org.justinvisser.wolfskin.design.motionDuration +import org.justinvisser.wolfskin.design.reducedMotion +import org.justinvisser.wolfskin.design.rowReveal +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M +import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S +import org.justinvisser.wolfskin.design.UpdateTypography as T +import org.justinvisser.wolfskin.design.WolfskinTokens.Update as U + +/** Controller lifetime belongs to the process, while navigation owns this screen's focus. */ +@Composable +fun UpdatesScreen(shell: ShellState, controller: UpdateController, onBack: () -> Unit, onClassic: () -> Unit, modifier: Modifier = Modifier) { + val state by controller.state.collectAsStateWithLifecycle() + val installed by controller.installedVersion.collectAsStateWithLifecycle() + val release by controller.release.collectAsStateWithLifecycle() + val autoCheck by controller.autoCheck.collectAsStateWithLifecycle() + val initialInstalled = remember { installed.versionName } + val updatedVersion = installed.versionName.takeIf { it != initialInstalled } + val presentation = presentUpdate(if (updatedVersion != null) UpdateUiState.UpToDate else state, release, updatedVersion) + val scroll = rememberScrollState() + val scope = rememberCoroutineScope() + val density = LocalDensity.current + val reduced = reducedMotion() + val focus = remember(controller, shell) { + UpdateFocus(controller, onBack, onClassic, { shell.navFocused = true; shell.navIndex = shell.currentTab }) { direction -> + scope.launch { scroll.animateScrollBy(with(density) { U.notesScrollStep.toPx() } * direction, + tween(motionDuration(M.scroll, reduced), easing = M.easeOut)) } + } + } + LaunchedEffect(state, presentation.actions) { focus.update(state, presentation.actions) } + LaunchedEffect(controller) { if (controller.state.value == UpdateUiState.Idle) controller.check(manual = true) } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { controller.onResume() } + DisposableEffect(shell, focus) { + shell.contentFocus = focus + onDispose { if (shell.contentFocus === focus) shell.contentFocus = null; shell.contentOverlayVisible = false } + } + LaunchedEffect(focus.notesOpen) { shell.contentOverlayVisible = focus.notesOpen } + Box(modifier.fillMaxSize().testTag("updates-screen")) { + Row(Modifier.offset(L.safeSide, U.headerTop).height(U.headerHeight), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) { + Avatar(shell.person, S.settingsHeaderAvatar, T.avatarLetterLibHeader, letterColor = C.muted) + Text("Updates", Modifier.alignByBaseline().exactLineBox(T.screenTitle), style = T.screenTitle, color = C.fg) + Text("Installed ${installed.versionName}", Modifier.alignByBaseline(), style = T.nav.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal), color = C.muted) + } + Text("Back · Settings", Modifier.align(Alignment.TopEnd).padding(end = L.safeSide).offset(y = U.backTop), style = T.label.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal), color = C.muted) + if (focus.notesOpen) { + NotesPanel(release, scroll) + } else { + UpdatePanel(presentation, focus, !shell.navFocused, state.javaClass) + AutoCheckRow(autoCheck, focus.autoFocused && !shell.navFocused, Modifier.offset(L.safeSide, U.autoTop), focus::toggleAuto) + } + } +} + +@Composable +private fun UpdatePanel(presentation: UpdatePresentation, focus: UpdateFocus, focused: Boolean, identity: Any) { + val shape = RoundedCornerShape(U.radius) + CssColumn(Modifier.offset(L.safeSide, U.contentTop).width(U.width).rowReveal(identity) + .background(C.surface, shape).border(U.border, C.border, shape).padding(U.padding + U.border)) { + if (presentation.title.isNotEmpty()) { + Text(presentation.title, Modifier.exactLineBox(T.heading), style = T.heading, color = C.fg) + Spacer(Modifier.height(U.titleBottom)) + } + if (presentation.summary.isNotEmpty()) { + Text(presentation.summary, Modifier.exactLineBox(T.body), style = T.body, color = C.muted) + if (presentation.paragraphs.isNotEmpty()) Spacer(Modifier.height(U.paragraphBottom)) + } + presentation.paragraphs.forEachIndexed { index, paragraph -> + Text(paragraph, Modifier.exactLineBox(T.body), style = T.body, color = C.muted) + if (index != presentation.paragraphs.lastIndex) Spacer(Modifier.height(U.paragraphBottom)) + } + if (presentation.progress) { + Spacer(Modifier.height(U.progressTop)) + UpdateProgress(presentation.fraction) + presentation.byteProgress?.let { + Spacer(Modifier.height(U.progressBottom)) + Text(it, Modifier.exactLineBox(T.body), style = T.body, color = C.muted) + } + } + Row(Modifier.padding(top = U.actionsTop), horizontalArrangement = Arrangement.spacedBy(U.actionGap)) { + presentation.actions.forEachIndexed { index, action -> + Box(Modifier.testTag("update-action-${action.command}")) { + PickerButton(action.label, focused && !focus.autoFocused && focus.actionIndex == index, action.primary, + textStyle = T.button, onFocus = { focus.focusAction(index) }) { focus.activate(index) } + } + } + } + } +} + +@Composable +private fun AutoCheckRow(enabled: Boolean, focused: Boolean, modifier: Modifier, onToggle: () -> Unit) { + val shape = RoundedCornerShape(WolfskinTokens.Radius.listRow) + Row(modifier.size(L.settingsRowWidth, L.settingsRowHeight).testTag("update-auto-check") + .semantics { role = Role.Switch; stateDescription = if (enabled) "On" else "Off" } + .focusScale(focused, WolfskinTokens.Focus.scaleRow) + .background(if (focused) C.fg else C.listRowRest, shape).clickable(onClick = onToggle) + .padding(horizontal = S.settingsRowPadding), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Auto-check for updates", Modifier.exactLineBox(T.listTitle), style = T.listTitle, color = if (focused) C.onFocus else C.fg) + Text("${if (enabled) "On" else "Off"} · checks only, never installs", Modifier.exactLineBox(T.caption), style = T.caption, color = if (focused) C.onFocus else C.muted) + } + Box(Modifier.size(U.toggleWidth, U.toggleHeight).background(if (focused || !enabled) C.surface3 else C.accentSoft, CircleShape)) { + Box(Modifier.align(if (enabled) Alignment.CenterEnd else Alignment.CenterStart).padding(horizontal = U.toggleInset) + .size(U.toggleThumb).background(if (enabled) C.accentText else C.muted, CircleShape)) + } + } +} + +@Composable +private fun NotesPanel(release: UpdateUiState.Available?, scroll: androidx.compose.foundation.ScrollState) { + val shape = RoundedCornerShape(U.radius) + Column(Modifier.offset(L.safeSide, U.contentTop).size(U.width, U.notesHeight).testTag("update-notes") + .focusRing(true, U.radius, WolfskinTokens.Focus.ringControlRing) + .background(C.surface, shape).border(U.border, C.border, shape) + .verticalScroll(scroll).padding(U.padding + U.border)) { + Text(updateMetadata(release), Modifier.exactLineBox(T.stateTitle), style = T.stateTitle.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), color = C.fg) + Spacer(Modifier.height(U.notesTitleBottom)) + release?.notes?.lineSequence()?.filter { it.isNotBlank() }?.forEach { paragraph -> + Row(Modifier.padding(vertical = U.notesGap / 2)) { + Text("•", Modifier.width(U.padding / 2), style = T.nav, color = C.muted) + Text(paragraph.removePrefix("- "), Modifier.exactLineBox(T.nav), style = T.nav.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal), color = C.muted) + } + } + Text("Sign-in stays on this TV. Android asks before installing.", Modifier.padding(top = U.notesGap), style = T.body, color = C.muted) + } +} diff --git a/wolfskin-design/generate_tokens.py b/wolfskin-design/generate_tokens.py index e18bc364..d036e496 100755 --- a/wolfskin-design/generate_tokens.py +++ b/wolfskin-design/generate_tokens.py @@ -121,9 +121,10 @@ def main() -> None: tokens.update(json.loads((ROOT / "title-tokens.json").read_text())) tokens.update(json.loads((ROOT / "home-tokens.json").read_text())) tokens.update(json.loads((ROOT / "discover-tokens.json").read_text())) + tokens.update(json.loads((ROOT / "update-tokens.json").read_text())) kotlin, css = render_tokens(tokens) outputs = {str(KOTLIN): kotlin, "tokens.css": css} - inputs = ["tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "generate_tokens.py"] + inputs = ["tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "update-tokens.json", "generate_tokens.py"] checksums = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in inputs} checksums.update({name: hashlib.sha256(content.encode()).hexdigest() for name, content in outputs.items()}) outputs["tokens.sha256"] = "".join(f"{checksum} {name}\n" for name, checksum in sorted(checksums.items())) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt index 4f1d281a..1600e51c 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt @@ -40,7 +40,7 @@ private class CssColumnHeight : ParentDataModifier { /** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */ @Composable -internal fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) { +fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) { val exact = remember { CssColumnHeight() } Layout(content, modifier.then(exact)) { children, constraints -> val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Identity.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Identity.kt index 266cbded..d7c5570f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Identity.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Identity.kt @@ -23,10 +23,10 @@ fun personColor(key: String): Color = when (key.lowercase()) { /** The same mark anchors the picker and the persistent navigation. */ @Composable -fun Wordmark(modifier: Modifier = Modifier) { +fun Wordmark(modifier: Modifier = Modifier, prefix: String = "Wolf", textStyle: TextStyle = WolfskinTokens.Type.wordmark) { Row(modifier) { - Text("Wolf", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.wordmark) - Text("skin", color = WolfskinTokens.Color.accent, style = WolfskinTokens.Type.wordmark) + Text(prefix, color = WolfskinTokens.Color.fg, style = textStyle) + Text("skin", color = WolfskinTokens.Color.accent, style = textStyle) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt index 40b8563c..929fce42 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt @@ -61,6 +61,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) val reduced = reducedMotion() val labelFade = tween(motionDuration(S.navLabelFade, reduced), easing = CubicBezierEasing(0f, 0f, .58f, 1f)) val index = if (state.navFocused) state.navIndex else state.currentTab + val hasSelectedTab = state.route != "settings" && state.route != "updates" val box = boxes[index] val avatarFocused = state.navFocused && index == state.tabs.size val pad = if (avatarFocused) L.navBlobAvatarPad else S.zero @@ -79,14 +80,15 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) Box(modifier.fillMaxWidth().height(L.navBand)) { Canvas(Modifier.matchParentSize()) { if (scrolled) drawRect(C.bg) else drawRect(band) - drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2)) + if (state.navFocused || hasSelectedTab) drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2)) } - Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop)) + Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop), if (hasSelectedTab) "Wolf" else "wolf", + if (hasSelectedTab) WolfskinTokens.Type.wordmark else UpdateTypography.wordmark) state.tabs.forEachIndexed { itemIndex, tab -> val item = boxes[itemIndex] val focused = state.navFocused && state.navIndex == itemIndex val labelColor by animateColorAsState( - if (focused) C.onFocus else if (itemIndex == state.currentTab) C.fg else C.muted, + if (focused) C.onFocus else if (hasSelectedTab && itemIndex == state.currentTab) C.fg else C.muted, animationSpec = labelFade, label = "nav-label-$tab", ) @@ -95,8 +97,8 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) .clickable { state.focusNav(itemIndex); state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) { - Text(tab.replaceFirstChar { it.uppercase() }, style = WolfskinTokens.Type.nav, color = labelColor) - if (itemIndex == state.currentTab && !focused) { + Text(tab.replaceFirstChar { it.uppercase() }, style = if (hasSelectedTab) WolfskinTokens.Type.nav else UpdateTypography.nav, color = labelColor) + if (hasSelectedTab && itemIndex == state.currentTab && !focused) { Box(Modifier.align(Alignment.BottomCenter).offset(y = -L.navUnderline).size(item.width - L.navItemPaddingX * 2, L.navUnderline).background(C.accent)) } } @@ -111,7 +113,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) } } val avatar = boxes.last() - Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav, + Avatar(state.person, L.navAvatar, if (hasSelectedTab) WolfskinTokens.Type.avatarLetterNav else UpdateTypography.avatarLetterNav, Modifier.offset(avatar.left, avatar.top) .focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarRing) .focusProperties { canFocus = false } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt index 08c79cd8..15c47744 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding @@ -36,50 +37,52 @@ 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 +import org.justinvisser.wolfskin.design.UpdateTypography as T /** Settings routes identify real upstream destinations rather than stand-in panels. */ data class SettingsGroup(val key: String, val title: String, val description: String, val wolfskin: Boolean = false) val SETTINGS_GROUPS = listOf( - SettingsGroup("requests", "Requests", "Coming in: what the household asked for, and how far each is"), - SettingsGroup("interface", "Interface", "Sign in automatically · Remember selected tabs · Theme music · Subtitle style · Application theme · Screensaver"), - SettingsGroup("playback", "Playback", "Skip forward · Skip back · Skip back when resuming"), - SettingsGroup("nextup", "Next up", "Show next up timing · Auto play next up · Delay · Passout protection"), - SettingsGroup("profile", "User profile settings", "Profile protection · Customize home page (Wolfskin presets: Default, Compact, Series thumbs, Episode thumbnails) · Navigation drawer items · Language", true), - SettingsGroup("about", "About", "Installed version · Update (Wolfskin releases on Forgejo) · Wolfskin about", true), - SettingsGroup("seerr", "Seerr integration", "Add, change or remove the Seerr server this TV asks for Discover and ratings", true), - SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"), + SettingsGroup("requests", "Requests", "Coming in: what the household asked for"), + SettingsGroup("updates", "Updates", "Check and install", true), + SettingsGroup("interface", "Interface", "Sign-in · Theme music · Subtitles"), + SettingsGroup("playback", "Playback", "Skip forward · Skip back · Resume"), + SettingsGroup("nextup", "Next up", "Auto-play · Delay · Passout protection"), + SettingsGroup("profile", "User profile settings", "Protection · Home · Language"), + SettingsGroup("about", "About", "Installed version · Wolfskin about"), + SettingsGroup("seerr", "Seerr integration", "Manage the Seerr server"), + SettingsGroup("advanced", "Advanced settings", "Playback backend · Logs · Image cache"), + SettingsGroup("classic", "Classic settings (trial)", "Original settings, including About → Update"), ) /** The count describes returned request history, not an inferred queue of active downloads. */ @Composable -fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null) { +fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") { val list = state.view.listState + val reduced = reducedMotion() val density = LocalDensity.current LaunchedEffect(state.settingsRow, state.navFocused) { if (state.navFocused) return@LaunchedEffect - val y = L.row_lhead + L.row_srow * state.settingsRow - val target = if (y >= L.windowAhead) y - L.pinnedLine else S.zero - val current = if (list.firstVisibleItemIndex == 0) S.zero else L.row_lhead + L.row_srow * (list.firstVisibleItemIndex - 1) - val distance = with(density) { (target - current).toPx() } - list.firstVisibleItemScrollOffset - list.animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut)) + val target = (state.settingsRow - WolfskinTokens.Update.settingsVisibleRows.toInt() + 1).coerceAtLeast(0) + val distance = with(density) { (L.row_srow * (target - list.firstVisibleItemIndex)).toPx() } - list.firstVisibleItemScrollOffset + list.animateScrollBy(distance, tween(if (reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut)) } - LazyColumn(modifier.fillMaxSize(), state = list, - contentPadding = PaddingValues(bottom = S.stageHeight - L.pinnedLine - L.row_srow)) { - item(key = "settings-header") { - Row(Modifier.height(L.row_lhead).padding(start = L.safeSide, top = S.settingsHeaderTop), - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) { - Avatar(state.person, S.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted) - Text("Settings", Modifier.alignByBaseline(), style = WolfskinTokens.Type.screenTitle, color = C.fg) - Text("Requests and Wholphin settings on this TV · ${state.person.name}", Modifier.alignByBaseline(), style = WolfskinTokens.Type.nav.copy(fontWeight = FontWeight.Normal), color = C.muted) - } + Box(modifier.fillMaxSize()) { + Row(Modifier.offset(L.safeSide, WolfskinTokens.Update.headerTop).height(WolfskinTokens.Update.headerHeight), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) { + Avatar(state.person, S.settingsHeaderAvatar, T.avatarLetterLibHeader, letterColor = C.muted) + Text("Settings", Modifier.alignByBaseline().exactLineBox(T.screenTitle), style = T.screenTitle, color = C.fg) + Text("This TV · ${state.person.name}", Modifier.alignByBaseline(), style = T.nav.copy(fontWeight = FontWeight.Normal), color = C.muted) } - itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group -> - Box(Modifier.height(L.row_srow)) { - SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow, - if (group.key == "requests") requestCount else null, - Modifier.offset(L.safeSide, S.settingsRowTop)) { - state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok) + LazyColumn(Modifier.fillMaxWidth().offset(y = WolfskinTokens.Update.rowsTop).height(WolfskinTokens.Update.rowsViewport + S.settingsRowGap), + state = list, contentPadding = PaddingValues(bottom = S.settingsRowGap)) { + itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group -> + Box(Modifier.height(L.row_srow)) { + val display = if (group.key == "updates") group.copy(description = "$installedVersion · Check and install") else group + SettingsRow(display, !state.navFocused && !state.requestsVisible && index == state.settingsRow, + if (group.key == "requests") requestCount else null, Modifier.offset(L.safeSide)) { + state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok) + } } } } @@ -94,14 +97,13 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, requestCount: Int?, modi .background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)) .clickable(onClick = onClick).padding(horizontal = S.settingsRowPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) { - Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon)) + Glyph(if (group.key == "updates") "updates" else "settings-list", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon)) Column(Modifier.weight(1f)) { val count = requestCount?.let { " · $it ${if (it == 1) "request" else "requests"}" } ?: "" - Text(group.title + count, Modifier.exactLineBox(WolfskinTokens.Type.listTitle), color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1) - Text(group.description, Modifier.exactLineBox(WolfskinTokens.Type.caption), color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(group.title + count, Modifier.exactLineBox(T.listTitle), color = foreground, style = T.listTitle, maxLines = 1) + Text(group.description, Modifier.exactLineBox(T.caption), color = if (focused) C.onFocus else C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) } - if (group.wolfskin) SettingsPill("Wolfskin", focused) - SettingsPill(if (group.key == "requests") "Wolfskin screen" else "upstream screen", focused) + SettingsPill(if (group.key == "requests" || group.key == "updates") "Wolfskin screen" else "upstream screen", focused) Glyph("right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsChevron)) } } @@ -122,6 +124,6 @@ private fun SettingsPill(label: String, focused: Boolean) { drawRoundRect(border, topLeft = inset, size = bounds, cornerRadius = radius, style = outline) } }.padding(horizontal = S.settingsPillPadding + S.settingsPillStroke), contentAlignment = Alignment.Center) { - Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = WolfskinTokens.Type.label.fontWeight)) + Text(label, color = foreground, style = T.caption.copy(fontWeight = T.label.fontWeight)) } } 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 c770a3ab..2992e93f 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 @@ -260,9 +260,13 @@ class ShellState( } } else if (route == "settings") { val group = SETTINGS_GROUPS[settingsRow].key - if (group == "requests") openRequests() else { - handOff = group - onAction(ShellAction.HandOff(group)) + when (group) { + "requests" -> openRequests() + "updates" -> onAction(ShellAction.Destination("updates")) + else -> { + handOff = group + onAction(ShellAction.HandOff(group)) + } } } else contentFocus?.key(ShellKey.Ok) @@ -305,6 +309,7 @@ class ShellState( gearFocused = true pickerIndex = people.lastIndex } else if (route !in tabs) { + if (route == "updates") navFocused = false onAction(ShellAction.Pop) } else if (!navFocused) { navFocused = true diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt index 603942b2..cf1da9c4 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt @@ -56,6 +56,7 @@ fun ShellSurface( modifier: Modifier = Modifier, handlesContentKeys: Boolean = true, settingsRequestCount: Int? = null, + settingsInstalledVersion: String = "", overlay: @Composable () -> Unit = {}, content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) }, ) { @@ -91,7 +92,7 @@ fun ShellSurface( if (state.pickerVisible) { WhosWatching(state) } else { - if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) + if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2) if (state.requestsVisible) ComingInSheet(state) overlay() diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/UpdateTypography.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/UpdateTypography.kt new file mode 100644 index 00000000..92ad49f0 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/UpdateTypography.kt @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight + +/** The approved Updates frames resolve system-ui to DejaVu Sans; other screens keep their fonts. */ +object UpdateTypography { + val family = FontFamily(Font(R.font.update_dejavu_sans), Font(R.font.update_dejavu_sans_bold, FontWeight.Bold)) + val screenTitle = WolfskinTokens.Type.screenTitle.copy(fontFamily = family) + val stateTitle = WolfskinTokens.Type.stateTitle.copy(fontFamily = family) + val heading = WolfskinTokens.Update.heading.copy(fontFamily = family) + val nav = WolfskinTokens.Type.nav.copy(fontFamily = family) + val wordmark = WolfskinTokens.Type.wordmark.copy(fontFamily = family) + val listTitle = WolfskinTokens.Type.listTitle.copy(fontFamily = family) + val body = WolfskinTokens.Type.body.copy(fontFamily = family) + val button = WolfskinTokens.Type.button.copy(fontFamily = family) + val label = WolfskinTokens.Type.label.copy(fontFamily = family) + val caption = WolfskinTokens.Type.caption.copy(fontFamily = family) + val avatarLetterNav = WolfskinTokens.Type.avatarLetterNav.copy(fontFamily = family) + val avatarLetterLibHeader = WolfskinTokens.Type.avatarLetterLibHeader.copy(fontFamily = family) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index 35ac83e3..0cfda025 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -209,6 +209,9 @@ object WolfskinTokens { const val fillInverse = "background color.fg, text color.onFocus" } object Motion { + const val updateScan = 1800 + const val updateSheen = 1600 + val linear = CubicBezierEasing(0.0f, 0.0f, 1.0f, 1.0f) const val skeletonCycle = 1400 const val skeletonLowAlpha = 0.55f const val focusMove = 180 @@ -698,4 +701,38 @@ object WolfskinTokens { const val ambientSaturation = 1.2f const val ambientBrightness = 0.55f } + object Update { + val heading = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(800), lineHeight = 20.160000000000004.sp, letterSpacing = -0.02.em) + const val settingsVisibleRows = 6.0f + val headerTop = 75.0.dp + val headerHeight = 42.0.dp + val rowsTop = 139.0.dp + val rowsViewport = 362.0.dp + val contentTop = 149.0.dp + val width = 650.0.dp + val padding = 20.0.dp + val border = 1.0.dp + val radius = 11.0.dp + val titleBottom = 8.0.dp + val paragraphBottom = 7.0.dp + val actionsTop = 18.0.dp + val actionGap = 12.0.dp + val progressTop = 15.0.dp + val progressBottom = 11.0.dp + val progressHeight = 6.0.dp + val progressRadius = 4.0.dp + val autoTop = 431.0.dp + val toggleWidth = 40.0.dp + val toggleHeight = 21.0.dp + val toggleThumb = 15.0.dp + val toggleInset = 3.0.dp + val notesHeight = 256.0.dp + val notesTitleBottom = 10.0.dp + val notesGap = 9.0.dp + val notesScrollStep = 40.0.dp + val backTop = 92.0.dp + const val scanFraction = 0.28f + const val scanEnd = 4.6f + const val sheenAlpha = 0.35f + } } diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_settings_list.xml b/wolfskin-design/src/main/res/drawable/wolfskin_settings_list.xml new file mode 100644 index 00000000..362cc295 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_settings_list.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_update.xml b/wolfskin-design/src/main/res/drawable/wolfskin_update.xml new file mode 100644 index 00000000..cfe21b10 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_update.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/font/update_dejavu_sans.ttf b/wolfskin-design/src/main/res/font/update_dejavu_sans.ttf new file mode 100644 index 00000000..1728e9ec Binary files /dev/null and b/wolfskin-design/src/main/res/font/update_dejavu_sans.ttf differ diff --git a/wolfskin-design/src/main/res/font/update_dejavu_sans_bold.ttf b/wolfskin-design/src/main/res/font/update_dejavu_sans_bold.ttf new file mode 100644 index 00000000..0a7fb3f0 Binary files /dev/null and b/wolfskin-design/src/main/res/font/update_dejavu_sans_bold.ttf differ diff --git a/wolfskin-design/src/main/res/raw/update_font_license.txt b/wolfskin-design/src/main/res/raw/update_font_license.txt new file mode 100644 index 00000000..272739c0 --- /dev/null +++ b/wolfskin-design/src/main/res/raw/update_font_license.txt @@ -0,0 +1,47 @@ +DejaVu Sans and DejaVu Sans Bold +https://dejavu-fonts.github.io/ + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. +Bitstream Vera is a trademark of Bitstream, Inc. +DejaVu changes are in public domain. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of the fonts accompanying this license ("Fonts") and associated +documentation files (the "Font Software"), to reproduce and distribute the +Font Software, including without limitation the rights to use, copy, merge, +publish, distribute, and/or sell copies of the Font Software, and to permit +persons to whom the Font Software is furnished to do so, subject to the +following conditions: + +The above copyright and trademark notices and this permission notice shall +be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular +the designs of glyphs or characters in the Fonts may be modified and +additional glyphs or characters may be added to the Fonts, only if the fonts +are renamed to names not containing either the words "Bitstream" or the word +"Vera". + +This License becomes null and void to the extent applicable to Fonts or Font +Software that has been modified and is distributed under the "Bitstream +Vera" names. + +The Font Software may be sold as part of a larger software package but no +copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, +TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME +FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING +ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE +FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome +Foundation, and Bitstream Inc., shall not be used in advertising or +otherwise to promote the sale, use or other dealings in this Font Software +without prior written authorization from the Gnome Foundation or Bitstream +Inc., respectively. For further information, contact: fonts at gnome dot +org. 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..ea452a66 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 @@ -54,10 +54,10 @@ class ShellKeysTest { assertEquals(state.tabs.size, state.navIndex) } - @Test fun playbackHandOffInElevenPresses() { - replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 11) + @Test fun playbackHandOffInTwelvePresses() { + replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 12) assertEquals("playback", state.handOff) - assertEquals(2, state.settingsRow) + assertEquals(3, state.settingsRow) } @Test fun onlyLastPersonReachesGearAndUpReturnsThere() { diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index bd65a73d..ef0f40c6 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -123,6 +123,9 @@ --layout-castCircle: 200.0px; --layout-personPortrait: 240.0px; --layout-row_chipsGrid: 136.0px; + --motion-updateScan: 1800ms; + --motion-updateSheen: 1600ms; + --motion-linear: cubic-bezier(0, 0, 1, 1); --motion-skeletonCycle: 1400ms; --motion-focusMove: 180ms; --motion-captionDelay: 60ms; @@ -513,4 +516,32 @@ --discover-stageGap: 10.0px; --discover-ambientInset: 80.0px; --discover-ambientBlur: 60.0px; + --update-heading: 36.0px; + --update-headerTop: 150.0px; + --update-headerHeight: 84.0px; + --update-rowsTop: 278.0px; + --update-rowsViewport: 724.0px; + --update-contentTop: 298.0px; + --update-width: 1300.0px; + --update-padding: 40.0px; + --update-border: 2.0px; + --update-radius: 22.0px; + --update-titleBottom: 16.0px; + --update-paragraphBottom: 14.0px; + --update-actionsTop: 36.0px; + --update-actionGap: 24.0px; + --update-progressTop: 30.0px; + --update-progressBottom: 22.0px; + --update-progressHeight: 12.0px; + --update-progressRadius: 8.0px; + --update-autoTop: 862.0px; + --update-toggleWidth: 80.0px; + --update-toggleHeight: 42.0px; + --update-toggleThumb: 30.0px; + --update-toggleInset: 6.0px; + --update-notesHeight: 512.0px; + --update-notesTitleBottom: 20.0px; + --update-notesGap: 18.0px; + --update-notesScrollStep: 80.0px; + --update-backTop: 184.0px; } diff --git a/wolfskin-design/tokens.json b/wolfskin-design/tokens.json index 28161fbc..c6e1ca85 100644 --- a/wolfskin-design/tokens.json +++ b/wolfskin-design/tokens.json @@ -1390,6 +1390,9 @@ } }, "motion": { + "updateScan": { "$type": "duration", "$value": "1800ms", "source": "approved Updates progress busy scan" }, + "updateSheen": { "$type": "duration", "$value": "1600ms", "source": "approved Updates download highlight" }, + "linear": { "$type": "cubicBezier", "$value": [0, 0, 1, 1] }, "skeletonCycle": { "$type": "duration", "$value": "1400ms" }, "skeletonLowAlpha": { "$type": "number", "$value": 0.55 }, "focusMove": { "$type": "duration", "$value": "180ms" }, diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index 8d5b523f..ef997d43 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,8 +1,9 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json -c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py +993d933950e17fb72d0902ffc440e1e4ed4dc088c96a8d12974431c77dc31634 generate_tokens.py 25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json -0446c2e85bbda72adde76329958931216fe81c43cebd47bbdb885f5dd59d87f8 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +18c08c6f4d77ee0690e63ec2e1ce14854eced539746c41c948942460b37a38b2 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json -faa4e0c5398d35ecab9b1088815b79c8de26a44d403c4cafae59c7c341992da7 tokens.css -f4424475291a745400a5daa071cb846838fb5e9ce889022288bcc6a7be758fe0 tokens.json +eed527dac5e09b209619cd67990556a5f9223d05e7e2516f300cb4760a198ca7 tokens.css +2e5e0476341adec1ec05331af38bc67cd02ba013a0988aea40a70eff21513264 tokens.json +824cb58d1507bc05212323ecfa66664f8b2bde23438940eed8717266161cac55 update-tokens.json diff --git a/wolfskin-design/update-tokens.json b/wolfskin-design/update-tokens.json new file mode 100644 index 00000000..35dc1a1f --- /dev/null +++ b/wolfskin-design/update-tokens.json @@ -0,0 +1,161 @@ +{ + "update": { + "heading": { + "$type": "typography", + "$value": {"fontWeight": 800}, + "px": 36, + "sp": 18, + "weight": 800, + "lineHeight": 1.12, + "letterSpacingEm": -0.02 + }, + "settingsVisibleRows": {"$type": "number", "$value": 6}, + "headerTop": { + "$type": "dimension", + "px": 150, + "dp": 75.0 + }, + "headerHeight": { + "$type": "dimension", + "px": 84, + "dp": 42.0 + }, + "rowsTop": { + "$type": "dimension", + "px": 278, + "dp": 139.0 + }, + "rowsViewport": { + "$type": "dimension", + "px": 724, + "dp": 362.0 + }, + "contentTop": { + "$type": "dimension", + "px": 298, + "dp": 149.0 + }, + "width": { + "$type": "dimension", + "px": 1300, + "dp": 650.0 + }, + "padding": { + "$type": "dimension", + "px": 40, + "dp": 20.0 + }, + "border": { + "$type": "dimension", + "px": 2, + "dp": 1.0 + }, + "radius": { + "$type": "dimension", + "px": 22, + "dp": 11.0 + }, + "titleBottom": { + "$type": "dimension", + "px": 16, + "dp": 8.0 + }, + "paragraphBottom": { + "$type": "dimension", + "px": 14, + "dp": 7.0 + }, + "actionsTop": { + "$type": "dimension", + "px": 36, + "dp": 18.0 + }, + "actionGap": { + "$type": "dimension", + "px": 24, + "dp": 12.0 + }, + "progressTop": { + "$type": "dimension", + "px": 30, + "dp": 15.0 + }, + "progressBottom": { + "$type": "dimension", + "px": 22, + "dp": 11.0 + }, + "progressHeight": { + "$type": "dimension", + "px": 12, + "dp": 6.0 + }, + "progressRadius": { + "$type": "dimension", + "px": 8, + "dp": 4.0 + }, + "autoTop": { + "$type": "dimension", + "px": 862, + "dp": 431.0 + }, + "toggleWidth": { + "$type": "dimension", + "px": 80, + "dp": 40.0 + }, + "toggleHeight": { + "$type": "dimension", + "px": 42, + "dp": 21.0 + }, + "toggleThumb": { + "$type": "dimension", + "px": 30, + "dp": 15.0 + }, + "toggleInset": { + "$type": "dimension", + "px": 6, + "dp": 3.0 + }, + "notesHeight": { + "$type": "dimension", + "px": 512, + "dp": 256.0 + }, + "notesTitleBottom": { + "$type": "dimension", + "px": 20, + "dp": 10.0 + }, + "notesGap": { + "$type": "dimension", + "px": 18, + "dp": 9.0 + }, + "notesScrollStep": { + "$type": "dimension", + "px": 80, + "dp": 40.0 + }, + "backTop": { + "$type": "dimension", + "px": 184, + "dp": 92.0 + }, + "scanFraction": { + "$type": "number", + "$value": 0.28 + }, + "scanEnd": { + "$type": "number", + "$value": 4.6 + }, + "sheenAlpha": { + "$type": "number", + "$value": 0.35 + } + } +}