Build the approved TV Updates settings UI #65
42 changed files with 1221 additions and 88 deletions
|
|
@ -8,6 +8,14 @@ Wolfskin is an Android TV client for one household. It is released through Forge
|
||||||
|
|
||||||
The app uses Wolfskin launcher artwork and a lilac-on-`#07070a` theme by default on a fresh install. Existing theme choices and all upstream colour themes remain available under Settings → App theme. Settings → About credits and links to Wholphin by damontecres.
|
The app uses Wolfskin launcher artwork and a lilac-on-`#07070a` theme by default on a fresh install. Existing theme choices and all upstream colour themes remain available under Settings → App theme. Settings → About credits and links to Wholphin by damontecres.
|
||||||
|
|
||||||
|
## Wolfskin TV updates
|
||||||
|
|
||||||
|
Settings opens on Requests. Press Down, then OK to open Updates; the row and screen show the installed app version. Left/Right selects an action, Down reaches Auto-check, and Up returns to the remembered action. Release notes keep focus inside the notes panel: Up/Down scrolls, and Back returns to the Release notes button.
|
||||||
|
|
||||||
|
Auto-check uses the same preference as classic settings and only checks for updates. Manual checks remain available when it is off. Download and Install update are separate, explicit actions; Android owns installation permission and confirmation. Returning from Android refreshes the installed version. Back can leave a download running, and returning to Updates shows the controller's current state.
|
||||||
|
|
||||||
|
Classic settings (trial) is the final Settings row. It opens the full original settings, including About → Update and More/Advanced → Updates. All existing settings groups remain available.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Wholphin - an OSS Android TV client for Jellyfin
|
# Wholphin - an OSS Android TV client for Jellyfin
|
||||||
|
|
|
||||||
|
|
@ -68,11 +68,16 @@ import javax.inject.Singleton
|
||||||
@Module
|
@Module
|
||||||
@TestInstallIn(
|
@TestInstallIn(
|
||||||
components = [SingletonComponent::class],
|
components = [SingletonComponent::class],
|
||||||
replaces = [DeviceModule::class, AppModule::class],
|
replaces = [DeviceModule::class, AppModule::class, org.justinvisser.wolfskin.tv.update.UpdateModule::class],
|
||||||
)
|
)
|
||||||
object TestModule {
|
object TestModule {
|
||||||
val testDispatcher = StandardTestDispatcher()
|
val testDispatcher = StandardTestDispatcher()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun updateController(): org.justinvisser.wolfskin.tv.update.UpdateController =
|
||||||
|
org.justinvisser.wolfskin.tv.update.FakeUpdateController()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun deviceInfo(
|
fun deviceInfo(
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ class AnimeAvailabilityTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true),
|
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 {
|
try {
|
||||||
requireNotNull(model.onForeground(server, user)).join()
|
requireNotNull(model.onForeground(server, user)).join()
|
||||||
assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs)
|
assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs)
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class AnimeRootRemovalTest {
|
||||||
val navigation = NavigationManager()
|
val navigation = NavigationManager()
|
||||||
val session = WolfskinSession()
|
val session = WolfskinSession()
|
||||||
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
|
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 {
|
try {
|
||||||
model.load(server, user)
|
model.load(server, user)
|
||||||
requireNotNull(model.onForeground(server, user)).join()
|
requireNotNull(model.onForeground(server, user)).join()
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ class ConductarrAudienceRecoveryTest {
|
||||||
val client = session.identity.value!!.client
|
val client = session.identity.value!!.client
|
||||||
val libraries = LibraryReadFacade(mockk())
|
val libraries = LibraryReadFacade(mockk())
|
||||||
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
|
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 {
|
try {
|
||||||
requireNotNull(model.onForeground(server, user)).join()
|
requireNotNull(model.onForeground(server, user)).join()
|
||||||
assertNull(libraries.audiences)
|
assertNull(libraries.audiences)
|
||||||
|
|
@ -82,7 +82,7 @@ class ConductarrAudienceRecoveryTest {
|
||||||
session.authenticate()
|
session.authenticate()
|
||||||
val libraries = LibraryReadFacade(mockk())
|
val libraries = LibraryReadFacade(mockk())
|
||||||
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
|
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 {
|
try {
|
||||||
val identity = session.identity.value!!
|
val identity = session.identity.value!!
|
||||||
val intent = org.justinvisser.wolfskin.design.DiscoverRequest(
|
val intent = org.justinvisser.wolfskin.design.DiscoverRequest(
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ class FirstSignInNavigationTest {
|
||||||
val navigation = NavigationManager()
|
val navigation = NavigationManager()
|
||||||
val session = WolfskinSession()
|
val session = WolfskinSession()
|
||||||
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
|
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(
|
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")))
|
HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume")))
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ class PersonPickerSessionTest {
|
||||||
val navigation = remember { NavigationManager() }
|
val navigation = remember { NavigationManager() }
|
||||||
val model = remember {
|
val model = remember {
|
||||||
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true),
|
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() } }
|
DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
|
||||||
WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ class PersonSwitchThreadingTest {
|
||||||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||||
val session = WolfskinSession()
|
val session = WolfskinSession()
|
||||||
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
|
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<ConductarrClient>()
|
val clients = mutableListOf<ConductarrClient>()
|
||||||
try {
|
try {
|
||||||
model.load(server, justin)
|
model.load(server, justin)
|
||||||
|
|
@ -163,7 +163,7 @@ class PersonSwitchThreadingTest {
|
||||||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||||
val session = WolfskinSession()
|
val session = WolfskinSession()
|
||||||
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
|
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 {
|
try {
|
||||||
model.load(server, justin)
|
model.load(server, justin)
|
||||||
requireNotNull(model.onForeground(server, justin)).join()
|
requireNotNull(model.onForeground(server, justin)).join()
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ class SavedPeoplePickerTest {
|
||||||
val session = WolfskinSession()
|
val session = WolfskinSession()
|
||||||
val navigation = NavigationManager()
|
val navigation = NavigationManager()
|
||||||
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true),
|
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 {
|
try {
|
||||||
rule.setContent {
|
rule.setContent {
|
||||||
WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class ShellStartupTimingTest {
|
||||||
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
|
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
|
||||||
val session = WolfskinSession()
|
val session = WolfskinSession()
|
||||||
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
|
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) }
|
val load = launch { model.load(server, user) }
|
||||||
try {
|
try {
|
||||||
withTimeout(5_000) { savedPeopleStarted.await() }
|
withTimeout(5_000) { savedPeopleStarted.await() }
|
||||||
|
|
@ -121,7 +121,7 @@ class ShellStartupTimingTest {
|
||||||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||||
val session = WolfskinSession()
|
val session = WolfskinSession()
|
||||||
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
|
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<Unit>()
|
val pending = CompletableDeferred<Unit>()
|
||||||
val release = java.util.concurrent.CountDownLatch(1)
|
val release = java.util.concurrent.CountDownLatch(1)
|
||||||
val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true)
|
val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv.update
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import org.justinvisser.wolfskin.design.ShellKey
|
||||||
|
|
||||||
|
/** 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", UpdateOperation.Download) 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 refreshedInstalledIdentityDoesNotRequireOrMaskAChannelCheck() {
|
||||||
|
val installed = "1.1.1"
|
||||||
|
val refreshed = presentUpdate(UpdateUiState.Idle, null, installed)
|
||||||
|
assertNotEquals(presentUpdate(UpdateUiState.Idle, null), refreshed)
|
||||||
|
assertEquals(listOf(UpdateCommand.Check), refreshed.actions.map { it.command })
|
||||||
|
assertEquals(presentUpdate(UpdateUiState.Checking, null),
|
||||||
|
presentUpdate(UpdateUiState.Checking, null, installed))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun percentageRequiresPositiveTotalAndIsBounded() {
|
||||||
|
listOf(null, 0L, -1L).forEach { total ->
|
||||||
|
val ui = presentUpdate(UpdateUiState.Downloading(20_100_000, total), updateReleaseFixture())
|
||||||
|
assertNull(ui.fraction)
|
||||||
|
}
|
||||||
|
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<Int>()
|
||||||
|
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.")
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv.update
|
||||||
|
|
||||||
|
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.test.assertIsDisplayed
|
||||||
|
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.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/** Native D-pad behavior preserves the retained shell, action focus and real scroller. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class UpdatesKeysTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
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(keys: List<Key>) {
|
||||||
|
keys.forEach { key ->
|
||||||
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
|
||||||
|
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
}
|
||||||
|
rule.mainClock.advanceTimeBy(320)
|
||||||
|
rule.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun classicRequiresEveryRetainedRowAndTheRealScroller() {
|
||||||
|
shell.settingsRow = 0
|
||||||
|
launch(UpdateUiState.Checking, settings = true)
|
||||||
|
repeat(9) { press(listOf(Key.DirectionDown)) }
|
||||||
|
assertEquals(9, shell.settingsRow)
|
||||||
|
assertEquals(4, shell.view.listState.firstVisibleItemIndex)
|
||||||
|
capture("classic")
|
||||||
|
press(listOf(Key.DirectionCenter))
|
||||||
|
assertEquals("classic", shell.handOff)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun notesTrapRealKeysAndBackPreservesSettingsRowAndScroller() {
|
||||||
|
launch(updateReleaseFixture())
|
||||||
|
press(listOf(Key.DirectionRight, Key.DirectionCenter, Key.DirectionUp, Key.DirectionRight, Key.DirectionDown))
|
||||||
|
assertTrue(shell.contentOverlayVisible)
|
||||||
|
assertFalse(shell.navFocused)
|
||||||
|
assertTrue(controller.autoCheck.value)
|
||||||
|
press(listOf(Key.Back))
|
||||||
|
assertFalse(shell.contentOverlayVisible)
|
||||||
|
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
||||||
|
press(listOf(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(listOf(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(listOf(Key.Back))
|
||||||
|
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun navAndAutoCheckReturnToTheRememberedActionWithoutToggling() {
|
||||||
|
launch(updateReleaseFixture())
|
||||||
|
press(listOf(Key.DirectionRight, Key.DirectionDown, Key.DirectionUp))
|
||||||
|
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
||||||
|
assertTrue(controller.autoCheck.value)
|
||||||
|
press(listOf(Key.DirectionUp))
|
||||||
|
assertTrue(shell.navFocused)
|
||||||
|
press(listOf(Key.DirectionDown))
|
||||||
|
assertFalse(shell.navFocused)
|
||||||
|
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
||||||
|
assertTrue(controller.autoCheck.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun refreshingInstalledIdentityCannotMaskAnOngoingCheck() {
|
||||||
|
launch(UpdateUiState.UpToDate)
|
||||||
|
rule.runOnIdle { controller.showInstalled(InstalledVersion("1.1.1", 100001)) }
|
||||||
|
rule.mainClock.advanceTimeBy(320)
|
||||||
|
rule.runOnIdle { controller.show(UpdateUiState.Checking) }
|
||||||
|
rule.mainClock.advanceTimeBy(320)
|
||||||
|
rule.onNodeWithTag("update-progress").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun progressKeepsAutoCheckFocusAcrossByteAndReducedMotionChanges() {
|
||||||
|
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)) }
|
||||||
|
if (target != null) {
|
||||||
|
repeat(101) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/download-%03d".format(frame)) }
|
||||||
|
}
|
||||||
|
press(listOf(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -80,6 +80,7 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
val seeAll: WolfskinSeeAllLists,
|
val seeAll: WolfskinSeeAllLists,
|
||||||
val conductarr: ConductarrSession,
|
val conductarr: ConductarrSession,
|
||||||
artwork: WolfskinArtwork,
|
artwork: WolfskinArtwork,
|
||||||
|
val updates: org.justinvisser.wolfskin.tv.update.UpdateController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
init { artwork.configure(context) }
|
init { artwork.configure(context) }
|
||||||
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
|
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
|
||||||
|
|
@ -195,7 +196,7 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
is ShellAction.Destination -> {
|
is ShellAction.Destination -> {
|
||||||
val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route)
|
val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route)
|
||||||
when {
|
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
|
action.preservePushed && navigation.backStack.size > 1 -> navigation.backStack[0] = target
|
||||||
else -> navigation.replace(target)
|
else -> navigation.replace(target)
|
||||||
}
|
}
|
||||||
|
|
@ -262,6 +263,7 @@ fun WolfskinApp(
|
||||||
{ shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) },
|
{ shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) },
|
||||||
) {
|
) {
|
||||||
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
|
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) }
|
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) }
|
androidx.lifecycle.compose.LifecycleEventEffect(androidx.lifecycle.Lifecycle.Event.ON_RESUME) { viewModel.onForeground(server, user) }
|
||||||
LaunchedEffect(identity?.signedIn, identity?.person) {
|
LaunchedEffect(identity?.signedIn, identity?.person) {
|
||||||
|
|
@ -303,7 +305,13 @@ fun WolfskinApp(
|
||||||
onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) },
|
onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) },
|
||||||
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
|
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
|
||||||
entryProvider = { destination ->
|
entryProvider = { destination ->
|
||||||
NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
|
NavEntry(
|
||||||
|
destination,
|
||||||
|
contentKey = "${destination}_${server.id}_${user.id}",
|
||||||
|
metadata = if (destination is Destination.Wolfskin && destination.route == "updates") {
|
||||||
|
org.justinvisser.wolfskin.tv.update.updatesRouteTransitions
|
||||||
|
} else emptyMap(),
|
||||||
|
) {
|
||||||
// Cached entries must not retain the launch shell after sign-in completes.
|
// Cached entries must not retain the launch shell after sign-in completes.
|
||||||
val state = LocalShellState.current
|
val state = LocalShellState.current
|
||||||
val discover = LocalDiscoverState.current
|
val discover = LocalDiscoverState.current
|
||||||
|
|
@ -329,11 +337,15 @@ fun WolfskinApp(
|
||||||
} else TitleDestination(route, state, preferences)
|
} else TitleDestination(route, state, preferences)
|
||||||
} else {
|
} else {
|
||||||
ShellSurface(state, handlesContentKeys = upstream == null,
|
ShellSurface(state, handlesContentKeys = upstream == null,
|
||||||
|
settingsInstalledVersion = installedVersion.versionName,
|
||||||
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
|
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
|
||||||
when {
|
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 || 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)
|
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
|
||||||
destination is Destination.Home -> homeContent(state, server, user, 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/") ->
|
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)
|
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)
|
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ interface UpdateController {
|
||||||
val state: StateFlow<UpdateUiState>
|
val state: StateFlow<UpdateUiState>
|
||||||
val autoCheck: StateFlow<Boolean>
|
val autoCheck: StateFlow<Boolean>
|
||||||
val installedVersion: StateFlow<InstalledVersion>
|
val installedVersion: StateFlow<InstalledVersion>
|
||||||
|
/** Candidate facts survive download/installer transitions and verified-file restoration. */
|
||||||
val release: StateFlow<UpdateUiState.Available?>
|
val release: StateFlow<UpdateUiState.Available?>
|
||||||
|
|
||||||
fun check(manual: Boolean)
|
fun check(manual: Boolean)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
// 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<UpdateAction>()
|
||||||
|
private var previousState: Class<*>? = null
|
||||||
|
|
||||||
|
/** Only a changed operation may retire an action; progress cannot steal focus. */
|
||||||
|
fun update(state: UpdateUiState, value: List<UpdateAction>) {
|
||||||
|
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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv.update
|
||||||
|
|
||||||
|
import androidx.compose.animation.EnterTransition
|
||||||
|
import androidx.compose.animation.ExitTransition
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.navigation3.ui.NavDisplay
|
||||||
|
|
||||||
|
/** Updates animates bounded content only; the retained nav never crossfades as a full scene. */
|
||||||
|
internal val updatesRouteTransitions =
|
||||||
|
NavDisplay.transitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } +
|
||||||
|
NavDisplay.popTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } +
|
||||||
|
NavDisplay.predictivePopTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) }
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
// 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<String> = emptyList(),
|
||||||
|
val actions: List<UpdateAction>,
|
||||||
|
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 -> updatedVersion?.let(::updatedPresentation)
|
||||||
|
?: UpdatePresentation(actions = listOf(UpdateAction("Check for updates", UpdateCommand.Check, true)))
|
||||||
|
UpdateUiState.Checking -> UpdatePresentation(actions = listOf(back), progress = true)
|
||||||
|
UpdateUiState.UpToDate -> updatedVersion?.let(::updatedPresentation)
|
||||||
|
?: UpdatePresentation("Up to date", "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.operation), 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 updatedPresentation(version: String) = UpdatePresentation(
|
||||||
|
"Updated to $version", "Sign-ins and settings kept.",
|
||||||
|
actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true)),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun failureTitle(kind: UpdateFailure, operation: UpdateOperation): String {
|
||||||
|
if (operation == UpdateOperation.Install) return "Install failed"
|
||||||
|
return when (kind) {
|
||||||
|
UpdateFailure.Network, UpdateFailure.HttpRefused, UpdateFailure.InsufficientStorage ->
|
||||||
|
if (operation == UpdateOperation.Check) "Check failed" else "Download failed"
|
||||||
|
UpdateFailure.InvalidMetadata ->
|
||||||
|
if (operation == UpdateOperation.Check) "Update unavailable" else "Verification failed"
|
||||||
|
UpdateFailure.UnsupportedAbi -> "Update unavailable"
|
||||||
|
UpdateFailure.ChecksumMismatch, UpdateFailure.WrongPackage, UpdateFailure.WrongSigner, UpdateFailure.LowerVersionCode -> "Verification failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
// 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.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.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
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.focusScale
|
||||||
|
import org.justinvisser.wolfskin.design.motionDuration
|
||||||
|
import org.justinvisser.wolfskin.design.reducedMotion
|
||||||
|
import org.justinvisser.wolfskin.design.resolveUpdateAvatarColor
|
||||||
|
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 initialInstalledCode = remember { installed.versionCode }
|
||||||
|
val updatedVersion = installed.versionName.takeIf { installed.versionCode > initialInstalledCode }
|
||||||
|
val presentation = remember(state, release, updatedVersion) {
|
||||||
|
presentUpdate(state, release, updatedVersion.takeIf { state == UpdateUiState.Idle || state == UpdateUiState.UpToDate })
|
||||||
|
}
|
||||||
|
val scroll = rememberScrollState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val reduced = reducedMotion()
|
||||||
|
val scrollDuration by rememberUpdatedState(motionDuration(M.scroll, reduced))
|
||||||
|
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(scrollDuration, 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, backgroundColor = resolveUpdateAvatarColor(shell.person))
|
||||||
|
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)
|
||||||
|
val paragraphs = remember(release?.notes) { release?.notes?.lineSequence()?.filter { it.isNotBlank() }?.toList().orEmpty() }
|
||||||
|
val notesStyle = T.nav.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal)
|
||||||
|
CssColumn(Modifier.offset(L.safeSide, U.contentTop).size(U.width, U.notesHeight).testTag("update-notes")
|
||||||
|
.drawWithCache {
|
||||||
|
val ring = WolfskinTokens.Focus.ringControlRing.toPx() / 2f
|
||||||
|
val radius = U.radius.toPx()
|
||||||
|
val inset = U.border.toPx() + ring / 2f
|
||||||
|
onDrawWithContent {
|
||||||
|
drawContent()
|
||||||
|
drawRoundRect(C.accent, Offset(-ring / 2f, -ring / 2f), Size(size.width + ring, size.height + ring),
|
||||||
|
CornerRadius(radius + ring / 2f), style = Stroke(ring))
|
||||||
|
drawRoundRect(C.accent, Offset(inset, inset), Size(size.width - inset * 2, size.height - inset * 2),
|
||||||
|
CornerRadius(radius - inset), style = Stroke(ring))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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.notesListTop))
|
||||||
|
paragraphs.forEachIndexed { index, paragraph ->
|
||||||
|
Row(Modifier.padding(start = U.notesMarkerOffset).exactLineBox(notesStyle)) {
|
||||||
|
Text("•", Modifier.width(U.notesMarkerWidth).exactLineBox(notesStyle), style = notesStyle, color = C.muted)
|
||||||
|
Text(paragraph.removePrefix("- "), Modifier.exactLineBox(notesStyle), style = notesStyle, color = C.muted)
|
||||||
|
}
|
||||||
|
if (index != paragraphs.lastIndex) Spacer(Modifier.height(U.notesGap))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(U.notesListBottom))
|
||||||
|
Text("Sign-in stays on this TV. Android asks before installing.", Modifier.exactLineBox(T.body), style = T.body, color = C.muted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -35,7 +35,7 @@ val cacheRobolectricSdk by tasks.registering(Copy::class) {
|
||||||
}
|
}
|
||||||
|
|
||||||
val checkTokenDrift by tasks.registering {
|
val checkTokenDrift by tasks.registering {
|
||||||
inputs.files(fileTree(projectDir) { include("tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "generate_tokens.py", "tokens.css", "tokens.sha256", "src/main/java/**/WolfskinTokens.kt") })
|
inputs.files(fileTree(projectDir) { include("tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "update-tokens.json", "generate_tokens.py", "tokens.css", "tokens.sha256", "src/main/java/**/WolfskinTokens.kt") })
|
||||||
doLast {
|
doLast {
|
||||||
file("tokens.sha256").readLines().forEach { line ->
|
file("tokens.sha256").readLines().forEach { line ->
|
||||||
val (expected, name) = line.split(" ", limit = 2)
|
val (expected, name) = line.split(" ", limit = 2)
|
||||||
|
|
|
||||||
|
|
@ -121,9 +121,10 @@ def main() -> None:
|
||||||
tokens.update(json.loads((ROOT / "title-tokens.json").read_text()))
|
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 / "home-tokens.json").read_text()))
|
||||||
tokens.update(json.loads((ROOT / "discover-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)
|
kotlin, css = render_tokens(tokens)
|
||||||
outputs = {str(KOTLIN): kotlin, "tokens.css": css}
|
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 = {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()})
|
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()))
|
outputs["tokens.sha256"] = "".join(f"{checksum} {name}\n" for name, checksum in sorted(checksums.items()))
|
||||||
|
|
|
||||||
47
wolfskin-design/src/main/assets/update-font-license.txt
Normal file
47
wolfskin-design/src/main/assets/update-font-license.txt
Normal file
|
|
@ -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.
|
||||||
|
|
@ -40,7 +40,7 @@ private class CssColumnHeight : ParentDataModifier {
|
||||||
|
|
||||||
/** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */
|
/** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */
|
||||||
@Composable
|
@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() }
|
val exact = remember { CssColumnHeight() }
|
||||||
Layout(content, modifier.then(exact)) { children, constraints ->
|
Layout(content, modifier.then(exact)) { children, constraints ->
|
||||||
val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) }
|
val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) }
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ import androidx.tv.material3.Icon
|
||||||
fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
|
fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
|
||||||
val resource = when (name) {
|
val resource = when (name) {
|
||||||
"gear" -> R.drawable.wolfskin_gear
|
"gear" -> R.drawable.wolfskin_gear
|
||||||
|
"updates" -> R.drawable.wolfskin_update
|
||||||
|
"settings-list" -> R.drawable.wolfskin_settings_list
|
||||||
|
"settings-right" -> R.drawable.wolfskin_settings_chevron
|
||||||
"requests" -> R.drawable.wolfskin_tray
|
"requests" -> R.drawable.wolfskin_tray
|
||||||
"interface" -> R.drawable.wolfskin_grid
|
"interface" -> R.drawable.wolfskin_grid
|
||||||
"play", "playback" -> R.drawable.wolfskin_play
|
"play", "playback" -> R.drawable.wolfskin_play
|
||||||
|
|
|
||||||
|
|
@ -21,19 +21,24 @@ fun personColor(key: String): Color = when (key.lowercase()) {
|
||||||
else -> WolfskinTokens.Color.accent
|
else -> WolfskinTokens.Color.accent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Updates has its own locked avatar swatch; other destinations retain household colours. */
|
||||||
|
fun resolveUpdateAvatarColor(person: ShellPerson): Color =
|
||||||
|
if (person.key.equals("justin", ignoreCase = true)) WolfskinTokens.Update.avatarJustin else personColor(person.key)
|
||||||
|
|
||||||
/** The same mark anchors the picker and the persistent navigation. */
|
/** The same mark anchors the picker and the persistent navigation. */
|
||||||
@Composable
|
@Composable
|
||||||
fun Wordmark(modifier: Modifier = Modifier) {
|
fun Wordmark(modifier: Modifier = Modifier, prefix: String = "Wolf", textStyle: TextStyle = WolfskinTokens.Type.wordmark) {
|
||||||
Row(modifier) {
|
Row(modifier) {
|
||||||
Text("Wolf", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.wordmark)
|
Text(prefix, color = WolfskinTokens.Color.fg, style = textStyle)
|
||||||
Text("skin", color = WolfskinTokens.Color.accent, style = WolfskinTokens.Type.wordmark)
|
Text("skin", color = WolfskinTokens.Color.accent, style = textStyle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Initials are drawn over opaque person colour at the exact slot size. */
|
/** Initials are drawn over opaque person colour at the exact slot size. */
|
||||||
@Composable
|
@Composable
|
||||||
fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier, letterColor: Color = WolfskinTokens.Color.onFocus) {
|
fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier, letterColor: Color = WolfskinTokens.Color.onFocus,
|
||||||
Box(modifier.size(diameter).background(personColor(person.key), CircleShape), contentAlignment = Alignment.Center) {
|
backgroundColor: Color = personColor(person.key)) {
|
||||||
|
Box(modifier.size(diameter).background(backgroundColor, CircleShape), contentAlignment = Alignment.Center) {
|
||||||
Text(person.initial, color = letterColor, style = textStyle)
|
Text(person.initial, color = letterColor, style = textStyle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,15 +40,16 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.SavedIndicator as V
|
||||||
/** Blob bounds retain their measurement while their draw coordinates spring between targets. */
|
/** Blob bounds retain their measurement while their draw coordinates spring between targets. */
|
||||||
data class NavBox(val left: Dp, val top: Dp, val width: Dp, val height: Dp)
|
data class NavBox(val left: Dp, val top: Dp, val width: Dp, val height: Dp)
|
||||||
|
|
||||||
fun navBoxes(tabs: List<String>): List<NavBox> {
|
fun navBoxes(tabs: List<String>, isUpdatesLayout: Boolean = false): List<NavBox> {
|
||||||
var left = L.safeSide + S.wordmarkWidth + L.wordmarkGap + L.navItemGap
|
val updates = WolfskinTokens.Update
|
||||||
|
var left = L.safeSide + (if (isUpdatesLayout) updates.wordmarkWidth else S.wordmarkWidth) + L.wordmarkGap + L.navItemGap
|
||||||
return tabs.map { tab ->
|
return tabs.map { tab ->
|
||||||
val width = when (tab) {
|
val width = when (tab) {
|
||||||
"home" -> S.navHomeWidth
|
"home" -> if (isUpdatesLayout) updates.navHomeWidth else S.navHomeWidth
|
||||||
"discover" -> S.navDiscoverWidth
|
"discover" -> if (isUpdatesLayout) updates.navDiscoverWidth else S.navDiscoverWidth
|
||||||
"movies" -> S.navMoviesWidth
|
"movies" -> if (isUpdatesLayout) updates.navMoviesWidth else S.navMoviesWidth
|
||||||
"shows" -> S.navShowsWidth
|
"shows" -> if (isUpdatesLayout) updates.navShowsWidth else S.navShowsWidth
|
||||||
else -> S.navAnimeWidth
|
else -> if (isUpdatesLayout) updates.navAnimeWidth else S.navAnimeWidth
|
||||||
}
|
}
|
||||||
NavBox(left, S.navItemTop, width, L.navItemHeight).also { left += width + L.navItemGap }
|
NavBox(left, S.navItemTop, width, L.navItemHeight).also { left += width + L.navItemGap }
|
||||||
} + NavBox(S.stageWidth - L.safeSide - L.navAvatar, S.navItemTop, L.navAvatar, L.navAvatar)
|
} + NavBox(S.stageWidth - L.safeSide - L.navAvatar, S.navItemTop, L.navAvatar, L.navAvatar)
|
||||||
|
|
@ -57,7 +58,8 @@ fun navBoxes(tabs: List<String>): List<NavBox> {
|
||||||
/** There is one blob, including when focus wraps around the nav avatar. */
|
/** There is one blob, including when focus wraps around the nav avatar. */
|
||||||
@Composable
|
@Composable
|
||||||
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
|
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
|
||||||
val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
|
val hasSelectedTab = state.route != "settings" && state.route != "updates"
|
||||||
|
val boxes = remember(state.person.hasAnime, hasSelectedTab) { navBoxes(state.tabs, isUpdatesLayout = !hasSelectedTab) }
|
||||||
val reduced = reducedMotion()
|
val reduced = reducedMotion()
|
||||||
val labelFade = tween<Color>(motionDuration(S.navLabelFade, reduced), easing = CubicBezierEasing(0f, 0f, .58f, 1f))
|
val labelFade = tween<Color>(motionDuration(S.navLabelFade, reduced), easing = CubicBezierEasing(0f, 0f, .58f, 1f))
|
||||||
val index = if (state.navFocused) state.navIndex else state.currentTab
|
val index = if (state.navFocused) state.navIndex else state.currentTab
|
||||||
|
|
@ -79,14 +81,15 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
|
||||||
Box(modifier.fillMaxWidth().height(L.navBand)) {
|
Box(modifier.fillMaxWidth().height(L.navBand)) {
|
||||||
Canvas(Modifier.matchParentSize()) {
|
Canvas(Modifier.matchParentSize()) {
|
||||||
if (scrolled) drawRect(C.bg) else drawRect(band)
|
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 ->
|
state.tabs.forEachIndexed { itemIndex, tab ->
|
||||||
val item = boxes[itemIndex]
|
val item = boxes[itemIndex]
|
||||||
val focused = state.navFocused && state.navIndex == itemIndex
|
val focused = state.navFocused && state.navIndex == itemIndex
|
||||||
val labelColor by animateColorAsState(
|
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,
|
animationSpec = labelFade,
|
||||||
label = "nav-label-$tab",
|
label = "nav-label-$tab",
|
||||||
)
|
)
|
||||||
|
|
@ -95,8 +98,8 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
|
||||||
.clickable {
|
.clickable {
|
||||||
state.focusNav(itemIndex); state.key(ShellKey.Ok)
|
state.focusNav(itemIndex); state.key(ShellKey.Ok)
|
||||||
}, contentAlignment = Alignment.Center) {
|
}, contentAlignment = Alignment.Center) {
|
||||||
Text(tab.replaceFirstChar { it.uppercase() }, style = WolfskinTokens.Type.nav, color = labelColor)
|
Text(tab.replaceFirstChar { it.uppercase() }, style = if (hasSelectedTab) WolfskinTokens.Type.nav else UpdateTypography.nav, color = labelColor)
|
||||||
if (itemIndex == state.currentTab && !focused) {
|
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))
|
Box(Modifier.align(Alignment.BottomCenter).offset(y = -L.navUnderline).size(item.width - L.navItemPaddingX * 2, L.navUnderline).background(C.accent))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -111,10 +114,11 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val avatar = boxes.last()
|
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)
|
Modifier.offset(avatar.left, avatar.top)
|
||||||
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarRing)
|
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarRing)
|
||||||
.focusProperties { canFocus = false }
|
.focusProperties { canFocus = false }
|
||||||
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) })
|
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) },
|
||||||
|
backgroundColor = if (hasSelectedTab) personColor(state.person.key) else resolveUpdateAvatarColor(state.person))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,12 @@ import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
|
@ -36,50 +35,53 @@ import androidx.tv.material3.Text
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
||||||
|
import org.justinvisser.wolfskin.design.UpdateTypography as T
|
||||||
|
|
||||||
/** Settings routes identify real upstream destinations rather than stand-in panels. */
|
/** 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)
|
data class SettingsGroup(val key: String, val title: String, val description: String)
|
||||||
|
|
||||||
val SETTINGS_GROUPS = listOf(
|
val SETTINGS_GROUPS = listOf(
|
||||||
SettingsGroup("requests", "Requests", "Coming in: what the household asked for, and how far each is"),
|
SettingsGroup("requests", "Requests", "Coming in: what the household asked for"),
|
||||||
SettingsGroup("interface", "Interface", "Sign in automatically · Remember selected tabs · Theme music · Subtitle style · Application theme · Screensaver"),
|
SettingsGroup("updates", "Updates", "Check and install"),
|
||||||
SettingsGroup("playback", "Playback", "Skip forward · Skip back · Skip back when resuming"),
|
SettingsGroup("interface", "Interface", "Sign-in · Theme music · Subtitles"),
|
||||||
SettingsGroup("nextup", "Next up", "Show next up timing · Auto play next up · Delay · Passout protection"),
|
SettingsGroup("playback", "Playback", "Skip forward · Skip back · Resume"),
|
||||||
SettingsGroup("profile", "User profile settings", "Profile protection · Customize home page (Wolfskin presets: Default, Compact, Series thumbs, Episode thumbnails) · Navigation drawer items · Language", true),
|
SettingsGroup("nextup", "Next up", "Auto-play · Delay · Passout protection"),
|
||||||
SettingsGroup("about", "About", "Installed version · Update (Wolfskin releases on Forgejo) · Wolfskin about", true),
|
SettingsGroup("profile", "User profile settings", "Protection · Home · Language"),
|
||||||
SettingsGroup("seerr", "Seerr integration", "Add, change or remove the Seerr server this TV asks for Discover and ratings", true),
|
SettingsGroup("about", "About", "Installed version · Wolfskin about"),
|
||||||
SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"),
|
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. */
|
/** The count describes returned request history, not an inferred queue of active downloads. */
|
||||||
@Composable
|
@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 list = state.view.listState
|
||||||
|
val reduced = reducedMotion()
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
LaunchedEffect(state.settingsRow, state.navFocused) {
|
LaunchedEffect(state.settingsRow, state.navFocused) {
|
||||||
if (state.navFocused) return@LaunchedEffect
|
if (state.navFocused) return@LaunchedEffect
|
||||||
val y = L.row_lhead + L.row_srow * state.settingsRow
|
val target = (state.settingsRow - WolfskinTokens.Update.settingsVisibleRows.toInt() + 1).coerceAtLeast(0)
|
||||||
val target = if (y >= L.windowAhead) y - L.pinnedLine else S.zero
|
val distance = with(density) { (L.row_srow * (target - list.firstVisibleItemIndex)).toPx() } - list.firstVisibleItemScrollOffset
|
||||||
val current = if (list.firstVisibleItemIndex == 0) S.zero else L.row_lhead + L.row_srow * (list.firstVisibleItemIndex - 1)
|
list.animateScrollBy(distance, tween(if (reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut))
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
LazyColumn(modifier.fillMaxSize(), state = list,
|
Box(modifier.fillMaxSize()) {
|
||||||
contentPadding = PaddingValues(bottom = S.stageHeight - L.pinnedLine - L.row_srow)) {
|
Row(Modifier.offset(L.safeSide, WolfskinTokens.Update.headerTop).height(WolfskinTokens.Update.headerHeight),
|
||||||
item(key = "settings-header") {
|
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) {
|
||||||
Row(Modifier.height(L.row_lhead).padding(start = L.safeSide, top = S.settingsHeaderTop),
|
Avatar(state.person, S.settingsHeaderAvatar, T.avatarLetterLibHeader, letterColor = C.muted, backgroundColor = resolveUpdateAvatarColor(state.person))
|
||||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) {
|
Text("Settings", Modifier.alignByBaseline().exactLineBox(T.screenTitle), style = T.screenTitle, color = C.fg)
|
||||||
Avatar(state.person, S.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted)
|
Text("This TV · ${state.person.name}", Modifier.alignByBaseline(), style = T.nav.copy(fontWeight = FontWeight.Normal), color = 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
|
LazyColumn(Modifier.fillMaxWidth().offset(y = WolfskinTokens.Update.rowsTop)
|
||||||
Box(Modifier.height(L.row_srow)) {
|
.height(WolfskinTokens.Update.rowsViewport + L.settingsRowHeight * (WolfskinTokens.Focus.scaleRow - 1f)),
|
||||||
SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow,
|
state = list, contentPadding = PaddingValues(bottom = S.settingsRowGap)) {
|
||||||
if (group.key == "requests") requestCount else null,
|
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
|
||||||
Modifier.offset(L.safeSide, S.settingsRowTop)) {
|
Box(Modifier.height(L.row_srow)) {
|
||||||
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)
|
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,15 +96,14 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, requestCount: Int?, modi
|
||||||
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
|
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
|
||||||
.clickable(onClick = onClick).padding(horizontal = S.settingsRowPadding),
|
.clickable(onClick = onClick).padding(horizontal = S.settingsRowPadding),
|
||||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) {
|
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)) {
|
Column(Modifier.weight(1f)) {
|
||||||
val count = requestCount?.let { " · $it ${if (it == 1) "request" else "requests"}" } ?: ""
|
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.title + count, Modifier.exactLineBox(T.listTitle), color = foreground, style = T.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.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" || group.key == "updates") "Wolfskin screen" else "upstream screen", focused)
|
||||||
SettingsPill(if (group.key == "requests") "Wolfskin screen" else "upstream screen", focused)
|
Glyph("settings-right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
|
||||||
Glyph("right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsChevron))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,6 +123,6 @@ private fun SettingsPill(label: String, focused: Boolean) {
|
||||||
drawRoundRect(border, topLeft = inset, size = bounds, cornerRadius = radius, style = outline)
|
drawRoundRect(border, topLeft = inset, size = bounds, cornerRadius = radius, style = outline)
|
||||||
}
|
}
|
||||||
}.padding(horizontal = S.settingsPillPadding + S.settingsPillStroke), contentAlignment = Alignment.Center) {
|
}.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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -260,9 +260,13 @@ class ShellState(
|
||||||
}
|
}
|
||||||
} else if (route == "settings") {
|
} else if (route == "settings") {
|
||||||
val group = SETTINGS_GROUPS[settingsRow].key
|
val group = SETTINGS_GROUPS[settingsRow].key
|
||||||
if (group == "requests") openRequests() else {
|
when (group) {
|
||||||
handOff = group
|
"requests" -> openRequests()
|
||||||
onAction(ShellAction.HandOff(group))
|
"updates" -> onAction(ShellAction.Destination("updates"))
|
||||||
|
else -> {
|
||||||
|
handOff = group
|
||||||
|
onAction(ShellAction.HandOff(group))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else contentFocus?.key(ShellKey.Ok)
|
else contentFocus?.key(ShellKey.Ok)
|
||||||
|
|
@ -305,6 +309,7 @@ class ShellState(
|
||||||
gearFocused = true
|
gearFocused = true
|
||||||
pickerIndex = people.lastIndex
|
pickerIndex = people.lastIndex
|
||||||
} else if (route !in tabs) {
|
} else if (route !in tabs) {
|
||||||
|
if (route == "updates") navFocused = false
|
||||||
onAction(ShellAction.Pop)
|
onAction(ShellAction.Pop)
|
||||||
} else if (!navFocused) {
|
} else if (!navFocused) {
|
||||||
navFocused = true
|
navFocused = true
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ fun ShellSurface(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
handlesContentKeys: Boolean = true,
|
handlesContentKeys: Boolean = true,
|
||||||
settingsRequestCount: Int? = null,
|
settingsRequestCount: Int? = null,
|
||||||
|
settingsInstalledVersion: String = "",
|
||||||
overlay: @Composable () -> Unit = {},
|
overlay: @Composable () -> Unit = {},
|
||||||
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
|
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
|
||||||
) {
|
) {
|
||||||
|
|
@ -91,7 +92,7 @@ fun ShellSurface(
|
||||||
if (state.pickerVisible) {
|
if (state.pickerVisible) {
|
||||||
WhosWatching(state)
|
WhosWatching(state)
|
||||||
} else {
|
} 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)
|
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
|
||||||
if (state.requestsVisible) ComingInSheet(state)
|
if (state.requestsVisible) ComingInSheet(state)
|
||||||
overlay()
|
overlay()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.PlatformTextStyle
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.style.LineHeightStyle
|
||||||
|
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))
|
||||||
|
private val lineBox = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None)
|
||||||
|
private val platform = PlatformTextStyle(includeFontPadding = false)
|
||||||
|
private fun resolveStyle(style: TextStyle) = style.copy(fontFamily = family, lineHeightStyle = lineBox, platformStyle = platform)
|
||||||
|
val screenTitle = resolveStyle(WolfskinTokens.Type.screenTitle)
|
||||||
|
val stateTitle = resolveStyle(WolfskinTokens.Type.stateTitle)
|
||||||
|
val heading = resolveStyle(WolfskinTokens.Update.heading)
|
||||||
|
val nav = resolveStyle(WolfskinTokens.Type.nav)
|
||||||
|
val wordmark = resolveStyle(WolfskinTokens.Type.wordmark)
|
||||||
|
val listTitle = resolveStyle(WolfskinTokens.Type.listTitle)
|
||||||
|
val body = resolveStyle(WolfskinTokens.Type.body)
|
||||||
|
val button = resolveStyle(WolfskinTokens.Type.button)
|
||||||
|
val label = resolveStyle(WolfskinTokens.Type.label)
|
||||||
|
val caption = resolveStyle(WolfskinTokens.Type.caption)
|
||||||
|
val avatarLetterNav = resolveStyle(WolfskinTokens.Type.avatarLetterNav)
|
||||||
|
val avatarLetterLibHeader = resolveStyle(WolfskinTokens.Type.avatarLetterLibHeader)
|
||||||
|
}
|
||||||
|
|
@ -221,6 +221,9 @@ object WolfskinTokens {
|
||||||
const val fillInverse = "background color.fg, text color.onFocus"
|
const val fillInverse = "background color.fg, text color.onFocus"
|
||||||
}
|
}
|
||||||
object Motion {
|
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 skeletonCycle = 1400
|
||||||
const val skeletonLowAlpha = 0.55f
|
const val skeletonLowAlpha = 0.55f
|
||||||
const val focusMove = 180
|
const val focusMove = 180
|
||||||
|
|
@ -700,4 +703,48 @@ object WolfskinTokens {
|
||||||
const val ambientSaturation = 1.2f
|
const val ambientSaturation = 1.2f
|
||||||
const val ambientBrightness = 0.55f
|
const val ambientBrightness = 0.55f
|
||||||
}
|
}
|
||||||
|
object Update {
|
||||||
|
val avatarJustin = ComposeColor(0xFF7AB8FF)
|
||||||
|
val wordmarkWidth = 81.796875.dp
|
||||||
|
val navHomeWidth = 74.6640625.dp
|
||||||
|
val navDiscoverWidth = 99.0703125.dp
|
||||||
|
val navMoviesWidth = 85.2578125.dp
|
||||||
|
val navShowsWidth = 80.578125.dp
|
||||||
|
val navAnimeWidth = 79.234375.dp
|
||||||
|
val notesListTop = 14.0.dp
|
||||||
|
val notesListBottom = 14.0.dp
|
||||||
|
val notesMarkerOffset = 5.5.dp
|
||||||
|
val notesMarkerWidth = 14.5.dp
|
||||||
|
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 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||||
|
<path android:pathData="M12,6 L22,16 L12,26" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.3" android:fillColor="@android:color/transparent" />
|
||||||
|
</vector>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||||
|
<path android:pathData="M5,8 H27 M5,16 H27 M5,24 H27" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.3" android:fillColor="@android:color/transparent" />
|
||||||
|
</vector>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||||
|
<path android:pathData="M26,14 A11,11 0,1 0,25 23 M26,5 V14 H17" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.3" android:fillColor="@android:color/transparent" />
|
||||||
|
</vector>
|
||||||
BIN
wolfskin-design/src/main/res/font/update_dejavu_sans.ttf
Normal file
BIN
wolfskin-design/src/main/res/font/update_dejavu_sans.ttf
Normal file
Binary file not shown.
BIN
wolfskin-design/src/main/res/font/update_dejavu_sans_bold.ttf
Normal file
BIN
wolfskin-design/src/main/res/font/update_dejavu_sans_bold.ttf
Normal file
Binary file not shown.
|
|
@ -68,7 +68,7 @@ class ShellCaptureTest {
|
||||||
@Test fun st014_discoverNav() = capture("st-014") { it.pickerVisible = false; it.navFocused = true; it.navIndex = 1 }
|
@Test fun st014_discoverNav() = capture("st-014") { it.pickerVisible = false; it.navFocused = true; it.navIndex = 1 }
|
||||||
@Test fun st091_settings() = capture("st-091") { it.pickerVisible = false; it.enterDestination("settings") }
|
@Test fun st091_settings() = capture("st-091") { it.pickerVisible = false; it.enterDestination("settings") }
|
||||||
@Test fun st092_requestsCountFixture() = capture("st-092", requestCount = 5) { it.pickerVisible = false; it.enterDestination("settings") }
|
@Test fun st092_requestsCountFixture() = capture("st-092", requestCount = 5) { it.pickerVisible = false; it.enterDestination("settings") }
|
||||||
@Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 }
|
@Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 5 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tests and debug builds share the same shell-only projection of the locked fixture. */
|
/** Tests and debug builds share the same shell-only projection of the locked fixture. */
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,22 @@ class ShellKeysTest {
|
||||||
assertFalse("Requests content, not the nav, must own focus", state.navFocused)
|
assertFalse("Requests content, not the nav, must own focus", state.navFocused)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun updatesUseTwoPressesFromRequestsWithoutClassicHandOff() {
|
||||||
|
val actions = mutableListOf<ShellAction>()
|
||||||
|
val shell = ShellState(fixturePeople(), "justin", actions::add).apply {
|
||||||
|
pickerVisible = false
|
||||||
|
enterDestination("settings")
|
||||||
|
}
|
||||||
|
rule.setContent { WolfskinTheme { ShellSurface(shell) } }
|
||||||
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput {
|
||||||
|
pressKey(Key.DirectionDown)
|
||||||
|
pressKey(Key.DirectionCenter)
|
||||||
|
}
|
||||||
|
assertEquals(1, shell.settingsRow)
|
||||||
|
assertNull(shell.handOff)
|
||||||
|
assertEquals(listOf(ShellAction.Destination("updates")), actions)
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun settingsBackBackInTenPresses() {
|
@Test fun settingsBackBackInTenPresses() {
|
||||||
replay(settings + listOf(Key.Back, Key.Back), 10)
|
replay(settings + listOf(Key.Back, Key.Back), 10)
|
||||||
assertEquals("home", state.route)
|
assertEquals("home", state.route)
|
||||||
|
|
@ -54,10 +70,10 @@ class ShellKeysTest {
|
||||||
assertEquals(state.tabs.size, state.navIndex)
|
assertEquals(state.tabs.size, state.navIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun playbackHandOffInElevenPresses() {
|
@Test fun playbackHandOffInTwelvePresses() {
|
||||||
replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 11)
|
replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 12)
|
||||||
assertEquals("playback", state.handOff)
|
assertEquals("playback", state.handOff)
|
||||||
assertEquals(2, state.settingsRow)
|
assertEquals(3, state.settingsRow)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun onlyLastPersonReachesGearAndUpReturnsThere() {
|
@Test fun onlyLastPersonReachesGearAndUpReturnsThere() {
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,9 @@
|
||||||
--layout-castCircle: 200.0px;
|
--layout-castCircle: 200.0px;
|
||||||
--layout-personPortrait: 240.0px;
|
--layout-personPortrait: 240.0px;
|
||||||
--layout-row_chipsGrid: 136.0px;
|
--layout-row_chipsGrid: 136.0px;
|
||||||
|
--motion-updateScan: 1800ms;
|
||||||
|
--motion-updateSheen: 1600ms;
|
||||||
|
--motion-linear: cubic-bezier(0, 0, 1, 1);
|
||||||
--motion-skeletonCycle: 1400ms;
|
--motion-skeletonCycle: 1400ms;
|
||||||
--motion-focusMove: 180ms;
|
--motion-focusMove: 180ms;
|
||||||
--motion-captionDelay: 60ms;
|
--motion-captionDelay: 60ms;
|
||||||
|
|
@ -516,4 +519,42 @@
|
||||||
--discover-stageGap: 10.0px;
|
--discover-stageGap: 10.0px;
|
||||||
--discover-ambientInset: 80.0px;
|
--discover-ambientInset: 80.0px;
|
||||||
--discover-ambientBlur: 60.0px;
|
--discover-ambientBlur: 60.0px;
|
||||||
|
--update-avatarJustin: #7ab8ff;
|
||||||
|
--update-wordmarkWidth: 163.59375px;
|
||||||
|
--update-navHomeWidth: 149.328125px;
|
||||||
|
--update-navDiscoverWidth: 198.140625px;
|
||||||
|
--update-navMoviesWidth: 170.515625px;
|
||||||
|
--update-navShowsWidth: 161.15625px;
|
||||||
|
--update-navAnimeWidth: 158.46875px;
|
||||||
|
--update-notesListTop: 28.0px;
|
||||||
|
--update-notesListBottom: 28.0px;
|
||||||
|
--update-notesMarkerOffset: 11.0px;
|
||||||
|
--update-notesMarkerWidth: 29.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-notesGap: 18.0px;
|
||||||
|
--update-notesScrollStep: 80.0px;
|
||||||
|
--update-backTop: 184.0px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1400,6 +1400,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"motion": {
|
"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" },
|
"skeletonCycle": { "$type": "duration", "$value": "1400ms" },
|
||||||
"skeletonLowAlpha": { "$type": "number", "$value": 0.55 },
|
"skeletonLowAlpha": { "$type": "number", "$value": 0.55 },
|
||||||
"focusMove": { "$type": "duration", "$value": "180ms" },
|
"focusMove": { "$type": "duration", "$value": "180ms" },
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
|
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
|
||||||
c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py
|
993d933950e17fb72d0902ffc440e1e4ed4dc088c96a8d12974431c77dc31634 generate_tokens.py
|
||||||
eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json
|
eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json
|
||||||
192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json
|
192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json
|
||||||
3e2ad9a57c15c2ed4713abf383807bb567b27b38eddaaa36c00025e078864e12 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
8efafb988e507e367630212db4af4f40b529be55d0f90577a7be6dd98a86b4d4 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||||
062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json
|
062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json
|
||||||
d7d6b17f8258083c879365f01e06729aa0fa12b69b7cd81e1bceabbbe6ab2a7d tokens.css
|
5f3b6d52c7ecfe49db59d2bd74acc1781ff7196db62bae3827d8f52b50d65aea tokens.css
|
||||||
ba0da02b03047fbc2f29d538d980e87aa237f8ae36545aa4234600243fe14929 tokens.json
|
3ed41f38aac01a608fb3e61c836180cf1ad76fe7cd1aac8b126f0a4adc0b2db1 tokens.json
|
||||||
|
19728f99b90bc40c0ebec37c5bcc45b383b89b023211490bb3235c1690237ae5 update-tokens.json
|
||||||
|
|
|
||||||
167
wolfskin-design/update-tokens.json
Normal file
167
wolfskin-design/update-tokens.json
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
{
|
||||||
|
"update": {
|
||||||
|
"avatarJustin": {"$type": "color", "$value": "#7ab8ff"},
|
||||||
|
"wordmarkWidth": {"$type": "dimension", "px": 163.59375, "dp": 81.796875},
|
||||||
|
"navHomeWidth": {"$type": "dimension", "px": 149.328125, "dp": 74.6640625},
|
||||||
|
"navDiscoverWidth": {"$type": "dimension", "px": 198.140625, "dp": 99.0703125},
|
||||||
|
"navMoviesWidth": {"$type": "dimension", "px": 170.515625, "dp": 85.2578125},
|
||||||
|
"navShowsWidth": {"$type": "dimension", "px": 161.15625, "dp": 80.578125},
|
||||||
|
"navAnimeWidth": {"$type": "dimension", "px": 158.46875, "dp": 79.234375},
|
||||||
|
"notesListTop": {"$type": "dimension", "px": 28, "dp": 14},
|
||||||
|
"notesListBottom": {"$type": "dimension", "px": 28, "dp": 14},
|
||||||
|
"notesMarkerOffset": {"$type": "dimension", "px": 11, "dp": 5.5},
|
||||||
|
"notesMarkerWidth": {"$type": "dimension", "px": 29, "dp": 14.5},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue