Match native updater frames and preserve D-pad focus behavior

This commit is contained in:
justinzeus 2026-11-07 17:55:09 +01:00
parent 2b8c729f62
commit 37ad87d058
16 changed files with 131 additions and 67 deletions

View file

@ -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. 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

View file

@ -29,11 +29,11 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode import org.robolectric.annotation.GraphicsMode
/** Native TV frames use the real shell, updater controller seam and Android key adapter. */ /** Native D-pad behavior preserves the retained shell, action focus and real scroller. */
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE) @GraphicsMode(GraphicsMode.Mode.NATIVE)
class UpdatesCaptureTest { class UpdatesKeysTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>() @get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply { private val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply {
pickerVisible = false pickerVisible = false
@ -78,41 +78,24 @@ class UpdatesCaptureTest {
} }
private fun press(keys: List<Key>) { private fun press(keys: List<Key>) {
keys.forEach { key -> rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(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.mainClock.advanceTimeBy(320)
rule.waitForIdle() rule.waitForIdle()
} }
@Test fun settings() { launch(UpdateUiState.Checking, settings = true); capture("settings") } @Test fun classicRequiresEveryRetainedRowAndTheRealScroller() {
@Test fun classic() { shell.settingsRow = 0
launch(UpdateUiState.Checking, settings = true) launch(UpdateUiState.Checking, settings = true)
repeat(8) { press(listOf(Key.DirectionDown)) } repeat(9) { press(listOf(Key.DirectionDown)) }
assertEquals(9, shell.settingsRow) assertEquals(9, shell.settingsRow)
assertEquals(4, shell.view.listState.firstVisibleItemIndex) assertEquals(4, shell.view.listState.firstVisibleItemIndex)
capture("classic") capture("classic")
} press(listOf(Key.DirectionCenter))
@Test fun checking() { launch(UpdateUiState.Checking); capture("checking") } assertEquals("classic", shell.handOff)
@Test fun current() { launch(UpdateUiState.UpToDate); capture("current") }
@Test fun available() { launch(updateReleaseFixture()); capture("available") }
@Test fun notes() { launch(updateReleaseFixture()); press(listOf(Key.DirectionRight)); capture("notes") }
@Test fun notesOpen() { launch(updateReleaseFixture()); press(listOf(Key.DirectionRight, Key.DirectionCenter)); capture("notes-open") }
@Test fun downloading() { launch(UpdateUiState.Downloading(20_100_000, 47_800_000)); capture("downloading") }
@Test fun ready() { launch(UpdateUiState.Ready("1.1.1")); capture("ready") }
@Test fun permission() { launch(UpdateUiState.PermissionNeeded); capture("permission") }
@Test fun handoff() { launch(UpdateUiState.HandingOff); capture("handoff") }
@Test fun cancelled() { launch(UpdateUiState.InstallCancelled); capture("cancelled") }
@Test fun failed() { launch(UpdateUiState.Failed(UpdateFailure.HttpRefused, "The download server refused the request (403).")); capture("failed") }
@Test fun offline() { launch(UpdateUiState.Offline); capture("offline") }
@Test fun noInstaller() { launch(UpdateUiState.NoInstaller); capture("no-installer") }
@Test fun noReleasePublished() { launch(UpdateUiState.NoReleasePublished); capture("no-release") }
@Test fun unknownDownloadLength() { launch(UpdateUiState.Downloading(20_100_000, null)); capture("unknown-total") }
@Test fun autoOn() { launch(updateReleaseFixture()); press(listOf(Key.DirectionDown)); capture("auto-on") }
@Test fun autoOff() { launch(updateReleaseFixture()); press(listOf(Key.DirectionDown, Key.DirectionCenter)); capture("auto-off") }
@Test fun updated() {
launch(UpdateUiState.UpToDate)
rule.runOnIdle { controller.showInstalled(InstalledVersion("1.1.1", 100001)) }
rule.mainClock.advanceTimeBy(320)
capture("updated")
} }
@Test fun notesTrapRealKeysAndBackPreservesSettingsRowAndScroller() { @Test fun notesTrapRealKeysAndBackPreservesSettingsRowAndScroller() {
@ -141,6 +124,19 @@ class UpdatesCaptureTest {
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex) 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() { @Test fun refreshingInstalledIdentityCannotMaskAnOngoingCheck() {
launch(UpdateUiState.UpToDate) launch(UpdateUiState.UpToDate)
rule.runOnIdle { controller.showInstalled(InstalledVersion("1.1.1", 100001)) } rule.runOnIdle { controller.showInstalled(InstalledVersion("1.1.1", 100001)) }
@ -150,7 +146,7 @@ class UpdatesCaptureTest {
rule.onNodeWithTag("update-progress").assertIsDisplayed() rule.onNodeWithTag("update-progress").assertIsDisplayed()
} }
@Test fun nativeProgressAndStateTransitionAtSixteenMillisecondFrames() { @Test fun progressKeepsAutoCheckFocusAcrossByteAndReducedMotionChanges() {
launch(UpdateUiState.Checking) launch(UpdateUiState.Checking)
val target = directory val target = directory
if (target != null) { if (target != null) {
@ -162,7 +158,6 @@ class UpdatesCaptureTest {
rule.runOnIdle { controller.show(UpdateUiState.Downloading(20_100_000, 47_800_000)) } rule.runOnIdle { controller.show(UpdateUiState.Downloading(20_100_000, 47_800_000)) }
rule.mainClock.advanceTimeByFrame() rule.mainClock.advanceTimeByFrame()
repeat(24) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/state-%03d".format(frame)) } repeat(24) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/state-%03d".format(frame)) }
assertEquals(12f, rule.onNodeWithTag("update-progress").fetchSemanticsNode().boundsInRoot.height, .01f)
if (target != null) { if (target != null) {
repeat(101) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/download-%03d".format(frame)) } repeat(101) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/download-%03d".format(frame)) }
} }

View file

@ -39,7 +39,6 @@ class UpdateFocus(
} }
fun focusAction(index: Int) { actionIndex = index; autoFocused = false } fun focusAction(index: Int) { actionIndex = index; autoFocused = false }
fun focusAuto() { autoFocused = true }
fun activate(index: Int) { fun activate(index: Int) {
focusAction(index) focusAction(index)

View file

@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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
@ -28,8 +27,14 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.LocalDensity
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
@ -47,7 +52,6 @@ import org.justinvisser.wolfskin.design.PickerButton
import org.justinvisser.wolfskin.design.ShellState import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.WolfskinTokens import org.justinvisser.wolfskin.design.WolfskinTokens
import org.justinvisser.wolfskin.design.exactLineBox import org.justinvisser.wolfskin.design.exactLineBox
import org.justinvisser.wolfskin.design.focusRing
import org.justinvisser.wolfskin.design.focusScale import org.justinvisser.wolfskin.design.focusScale
import org.justinvisser.wolfskin.design.motionDuration import org.justinvisser.wolfskin.design.motionDuration
import org.justinvisser.wolfskin.design.reducedMotion import org.justinvisser.wolfskin.design.reducedMotion
@ -76,10 +80,11 @@ fun UpdatesScreen(shell: ShellState, controller: UpdateController, onBack: () ->
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val density = LocalDensity.current val density = LocalDensity.current
val reduced = reducedMotion() val reduced = reducedMotion()
val scrollDuration by rememberUpdatedState(motionDuration(M.scroll, reduced))
val focus = remember(controller, shell) { val focus = remember(controller, shell) {
UpdateFocus(controller, onBack, onClassic, { shell.navFocused = true; shell.navIndex = shell.currentTab }) { direction -> UpdateFocus(controller, onBack, onClassic, { shell.navFocused = true; shell.navIndex = shell.currentTab }) { direction ->
scope.launch { scroll.animateScrollBy(with(density) { U.notesScrollStep.toPx() } * direction, scope.launch { scroll.animateScrollBy(with(density) { U.notesScrollStep.toPx() } * direction,
tween(motionDuration(M.scroll, reduced), easing = M.easeOut)) } tween(scrollDuration, easing = M.easeOut)) }
} }
} }
LaunchedEffect(state, presentation.actions) { focus.update(state, presentation.actions) } LaunchedEffect(state, presentation.actions) { focus.update(state, presentation.actions) }
@ -168,7 +173,18 @@ private fun NotesPanel(release: UpdateUiState.Available?, scroll: androidx.compo
val paragraphs = remember(release?.notes) { release?.notes?.lineSequence()?.filter { it.isNotBlank() }?.toList().orEmpty() } 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) 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") CssColumn(Modifier.offset(L.safeSide, U.contentTop).size(U.width, U.notesHeight).testTag("update-notes")
.focusRing(true, U.radius, WolfskinTokens.Focus.ringControlRing) .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) .background(C.surface, shape).border(U.border, C.border, shape)
.verticalScroll(scroll).padding(U.padding + U.border)) { .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) Text(updateMetadata(release), Modifier.exactLineBox(T.stateTitle), style = T.stateTitle.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), color = C.fg)

View file

@ -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

View file

@ -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,11 +58,11 @@ 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
val hasSelectedTab = state.route != "settings" && state.route != "updates"
val box = boxes[index] val box = boxes[index]
val avatarFocused = state.navFocused && index == state.tabs.size val avatarFocused = state.navFocused && index == state.tabs.size
val pad = if (avatarFocused) L.navBlobAvatarPad else S.zero val pad = if (avatarFocused) L.navBlobAvatarPad else S.zero

View file

@ -9,14 +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.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
@ -40,11 +38,11 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
import org.justinvisser.wolfskin.design.UpdateTypography as T 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"), SettingsGroup("requests", "Requests", "Coming in: what the household asked for"),
SettingsGroup("updates", "Updates", "Check and install", true), SettingsGroup("updates", "Updates", "Check and install"),
SettingsGroup("interface", "Interface", "Sign-in · Theme music · Subtitles"), SettingsGroup("interface", "Interface", "Sign-in · Theme music · Subtitles"),
SettingsGroup("playback", "Playback", "Skip forward · Skip back · Resume"), SettingsGroup("playback", "Playback", "Skip forward · Skip back · Resume"),
SettingsGroup("nextup", "Next up", "Auto-play · Delay · Passout protection"), SettingsGroup("nextup", "Next up", "Auto-play · Delay · Passout protection"),
@ -74,7 +72,8 @@ fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount:
Text("Settings", Modifier.alignByBaseline().exactLineBox(T.screenTitle), style = T.screenTitle, color = C.fg) Text("Settings", Modifier.alignByBaseline().exactLineBox(T.screenTitle), style = T.screenTitle, color = C.fg)
Text("This TV · ${state.person.name}", Modifier.alignByBaseline(), style = T.nav.copy(fontWeight = FontWeight.Normal), color = C.muted) Text("This TV · ${state.person.name}", Modifier.alignByBaseline(), style = T.nav.copy(fontWeight = FontWeight.Normal), color = C.muted)
} }
LazyColumn(Modifier.fillMaxWidth().offset(y = WolfskinTokens.Update.rowsTop).height(WolfskinTokens.Update.rowsViewport + S.settingsRowGap), LazyColumn(Modifier.fillMaxWidth().offset(y = WolfskinTokens.Update.rowsTop)
.height(WolfskinTokens.Update.rowsViewport + L.settingsRowHeight * (WolfskinTokens.Focus.scaleRow - 1f)),
state = list, contentPadding = PaddingValues(bottom = S.settingsRowGap)) { state = list, contentPadding = PaddingValues(bottom = S.settingsRowGap)) {
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group -> itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
Box(Modifier.height(L.row_srow)) { Box(Modifier.height(L.row_srow)) {
@ -104,7 +103,7 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, requestCount: Int?, modi
Text(group.description, Modifier.exactLineBox(T.caption), color = if (focused) C.onFocus else C.muted, style = T.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)
} }
SettingsPill(if (group.key == "requests" || group.key == "updates") "Wolfskin screen" else "upstream screen", focused) SettingsPill(if (group.key == "requests" || group.key == "updates") "Wolfskin screen" else "upstream screen", focused)
Glyph("right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsChevron)) Glyph("settings-right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
} }
} }

View file

@ -1,6 +1,9 @@
// SPDX-License-Identifier: GPL-2.0-only // SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design 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.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -8,16 +11,19 @@ import androidx.compose.ui.text.font.FontWeight
/** The approved Updates frames resolve system-ui to DejaVu Sans; other screens keep their fonts. */ /** The approved Updates frames resolve system-ui to DejaVu Sans; other screens keep their fonts. */
object UpdateTypography { object UpdateTypography {
val family = FontFamily(Font(R.font.update_dejavu_sans), Font(R.font.update_dejavu_sans_bold, FontWeight.Bold)) val family = FontFamily(Font(R.font.update_dejavu_sans), Font(R.font.update_dejavu_sans_bold, FontWeight.Bold))
val screenTitle = WolfskinTokens.Type.screenTitle.copy(fontFamily = family) private val lineBox = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None)
val stateTitle = WolfskinTokens.Type.stateTitle.copy(fontFamily = family) private val platform = PlatformTextStyle(includeFontPadding = false)
val heading = WolfskinTokens.Update.heading.copy(fontFamily = family) private fun resolveStyle(style: TextStyle) = style.copy(fontFamily = family, lineHeightStyle = lineBox, platformStyle = platform)
val nav = WolfskinTokens.Type.nav.copy(fontFamily = family) val screenTitle = resolveStyle(WolfskinTokens.Type.screenTitle)
val wordmark = WolfskinTokens.Type.wordmark.copy(fontFamily = family) val stateTitle = resolveStyle(WolfskinTokens.Type.stateTitle)
val listTitle = WolfskinTokens.Type.listTitle.copy(fontFamily = family) val heading = resolveStyle(WolfskinTokens.Update.heading)
val body = WolfskinTokens.Type.body.copy(fontFamily = family) val nav = resolveStyle(WolfskinTokens.Type.nav)
val button = WolfskinTokens.Type.button.copy(fontFamily = family) val wordmark = resolveStyle(WolfskinTokens.Type.wordmark)
val label = WolfskinTokens.Type.label.copy(fontFamily = family) val listTitle = resolveStyle(WolfskinTokens.Type.listTitle)
val caption = WolfskinTokens.Type.caption.copy(fontFamily = family) val body = resolveStyle(WolfskinTokens.Type.body)
val avatarLetterNav = WolfskinTokens.Type.avatarLetterNav.copy(fontFamily = family) val button = resolveStyle(WolfskinTokens.Type.button)
val avatarLetterLibHeader = WolfskinTokens.Type.avatarLetterLibHeader.copy(fontFamily = family) val label = resolveStyle(WolfskinTokens.Type.label)
val caption = resolveStyle(WolfskinTokens.Type.caption)
val avatarLetterNav = resolveStyle(WolfskinTokens.Type.avatarLetterNav)
val avatarLetterLibHeader = resolveStyle(WolfskinTokens.Type.avatarLetterLibHeader)
} }

View file

@ -703,6 +703,12 @@ object WolfskinTokens {
} }
object Update { object Update {
val avatarJustin = ComposeColor(0xFF7AB8FF) 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 notesListTop = 14.0.dp
val notesListBottom = 14.0.dp val notesListBottom = 14.0.dp
val notesMarkerOffset = 5.5.dp val notesMarkerOffset = 5.5.dp

View file

@ -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>

View file

@ -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. */

View file

@ -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)

View file

@ -517,6 +517,12 @@
--discover-ambientInset: 80.0px; --discover-ambientInset: 80.0px;
--discover-ambientBlur: 60.0px; --discover-ambientBlur: 60.0px;
--update-avatarJustin: #7ab8ff; --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-notesListTop: 28.0px;
--update-notesListBottom: 28.0px; --update-notesListBottom: 28.0px;
--update-notesMarkerOffset: 11.0px; --update-notesMarkerOffset: 11.0px;

View file

@ -2,8 +2,8 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-token
993d933950e17fb72d0902ffc440e1e4ed4dc088c96a8d12974431c77dc31634 generate_tokens.py 993d933950e17fb72d0902ffc440e1e4ed4dc088c96a8d12974431c77dc31634 generate_tokens.py
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json 25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json
1b8222d01dc30cbe2591097218a4a93c6ac4b24704cf81b18174b7839eb57e31 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt a951ee1e5c433789e22cc995a7ed279d170b24d5bfbd1672fa9799c9282c6fb8 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json
9a4c316e83af3207dd21fa7a3b4a70711460f38b740ee1de3ec9b042b3d4f9a8 tokens.css 27b0e44afaece06f732d24ff847140da6175ea352de620fa6de9d7e9e4c101fa tokens.css
2e5e0476341adec1ec05331af38bc67cd02ba013a0988aea40a70eff21513264 tokens.json 2e5e0476341adec1ec05331af38bc67cd02ba013a0988aea40a70eff21513264 tokens.json
1bedb8e8079b4a954651d8761d18b8be4adb3933370e5f296be27287401ae460 update-tokens.json 19728f99b90bc40c0ebec37c5bcc45b383b89b023211490bb3235c1690237ae5 update-tokens.json

View file

@ -1,6 +1,12 @@
{ {
"update": { "update": {
"avatarJustin": {"$type": "color", "$value": "#7ab8ff"}, "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}, "notesListTop": {"$type": "dimension", "px": 28, "dp": 14},
"notesListBottom": {"$type": "dimension", "px": 28, "dp": 14}, "notesListBottom": {"$type": "dimension", "px": 28, "dp": 14},
"notesMarkerOffset": {"$type": "dimension", "px": 11, "dp": 5.5}, "notesMarkerOffset": {"$type": "dimension", "px": 11, "dp": 5.5},