Merge the corrected TV shell into Discover

This commit is contained in:
justinzeus 2026-11-05 16:19:00 +01:00
commit 7e8e232d47
11 changed files with 255 additions and 53 deletions

View file

@ -0,0 +1,141 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertIsNotFocused
import androidx.compose.ui.test.captureToImage
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.unit.dp
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.BackdropStyle
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropResult
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import java.io.File
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.ShellPerson
import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.ShellSurface
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** The rollout-off path must preserve native content focus and its backdrop beneath the shell. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class UpstreamContentFocusTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Before fun prepare() {
WolfskinScreenPreference.initialize(rule.activity)
WolfskinScreenPreference.enabled = false
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
}
@After fun restore() { WolfskinScreenPreference.enabled = true }
private fun shellState() = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply {
pickerVisible = false
navFocused = false
}
@Test fun switchOffUpMovesBetweenContentRowsBeforeEnteringNav() {
val state = shellState()
val first = FocusRequester()
val second = FocusRequester()
rule.setContent {
val upstream = resolveUpstreamDestination(Destination.Home(), WolfskinScreenPreference.enabled, emptyMap())
WolfskinTheme {
ShellSurface(state, handlesContentKeys = upstream == null) { contentModifier ->
Column(contentModifier.padding(start = 100.dp, top = 150.dp), verticalArrangement = Arrangement.spacedBy(32.dp)) {
Box(Modifier.size(200.dp, 60.dp).testTag("upstream-first").focusRequester(first).focusable())
Box(Modifier.size(200.dp, 60.dp).testTag("upstream-second").focusRequester(second).focusable())
}
}
}
}
rule.runOnIdle { second.requestFocus() }
rule.onNodeWithTag("upstream-second").assertIsFocused()
rule.onNodeWithTag("upstream-second").performKeyInput { pressKey(Key.DirectionUp) }
rule.onNodeWithTag("upstream-first").assertIsFocused()
rule.runOnIdle { assertFalse("Up from the second row must not enter nav", state.navFocused) }
rule.onNodeWithTag("upstream-first").performKeyInput { pressKey(Key.DirectionUp) }
rule.runOnIdle { assertTrue("Only the first row exits upward to nav", state.navFocused) }
rule.onNodeWithTag("upstream-first").assertIsNotFocused()
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown) }
rule.onNodeWithTag("upstream-first").assertIsFocused()
}
@Test fun switchOffShowsRealUpstreamBackdropAndSwitchOnRestoresOwnedBackground() {
val state = shellState()
val model = mockk<WolfskinShellViewModel>(relaxed = true)
every { model.session } returns WolfskinSession().also { it.state = state }
every { model.libraryDestinations } returns emptyMap()
// An unavailable saved identity uses the real sign-in placeholder, avoiding any server reads.
every { model.signedInPerson } returns "unavailable"
every { model.backdropFlow } returns MutableStateFlow(BackdropResult("fixture", null, Color.Red, Color.Red, Color.Red))
coEvery { model.load(any(), any()) } returns Unit
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
val defaults = AppPreferences.getDefaultInstance()
val preferences = UserPreferences(defaults.toBuilder().setInterfacePreferences(defaults.interfacePreferences.toBuilder().setBackdropStyle(BackdropStyle.BACKDROP_DYNAMIC_COLOR)).build(), null)
val navigation = NavigationManager()
rule.setContent { WolfskinApp(server, user, navigation, preferences, Modifier.fillMaxSize(), model) }
rule.mainClock.advanceTimeBy(1500)
rule.waitForIdle()
val off = rule.onRoot().captureToImage().toPixelMap()[1700, 900]
assertTrue("The real dynamic backdrop must remain visible through native content", off.red > .4f && off.green < .1f && off.blue < .1f)
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")))
directory.mkdirs()
rule.onRoot().captureRoboImage(File(directory, "review-upstream-off.png").absolutePath)
rule.runOnIdle { WolfskinScreenPreference.enabled = true }
rule.mainClock.advanceTimeBy(1500)
rule.waitForIdle()
val on = rule.onRoot().captureToImage().toPixelMap()[1700, 900]
val background = org.justinvisser.wolfskin.design.WolfskinTokens.Color.bg
org.junit.Assert.assertEquals("Owned background red", background.red, on.red, 1f / 255f)
org.junit.Assert.assertEquals("Owned background green", background.green, on.green, 1f / 255f)
org.junit.Assert.assertEquals("Owned background blue", background.blue, on.blue, 1f / 255f)
rule.onRoot().captureRoboImage(File(directory, "review-upstream-on.png").absolutePath)
}
}

View file

@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.tv
import android.content.Context import android.content.Context
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
@ -15,6 +16,7 @@ import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
@ -23,10 +25,12 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropResult
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.ui.nav.Backdrop
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.DestinationContent import com.github.damontecres.wholphin.ui.nav.DestinationContent
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
@ -35,6 +39,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.justinvisser.wolfskin.design.PeopleStatus import org.justinvisser.wolfskin.design.PeopleStatus
@ -68,6 +73,7 @@ class WolfskinShellViewModel @Inject constructor(
private val backdrop: BackdropService, private val backdrop: BackdropService,
private val libraries: LibraryReadFacade, private val libraries: LibraryReadFacade,
) : ViewModel() { ) : ViewModel() {
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
var libraryDestinations by mutableStateOf<Map<String, Destination>>(emptyMap()) var libraryDestinations by mutableStateOf<Map<String, Destination>>(emptyMap())
private set private set
var signedInPerson by mutableStateOf("") var signedInPerson by mutableStateOf("")
@ -157,36 +163,47 @@ fun WolfskinApp(
) )
WolfskinTheme { WolfskinTheme {
CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover) { CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover) {
NavDisplay( Box(modifier) {
backStack = navigationManager.backStack, val current = navigationManager.backStack.last()
onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }, if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) {
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()), val backdrop by viewModel.backdropFlow.collectAsStateWithLifecycle()
entryProvider = { destination -> Backdrop(
NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") { backdrop = backdrop,
val route = when (destination) { drawerIsOpen = false,
is Destination.Home -> "home" backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle,
is Destination.Wolfskin -> destination.route )
else -> "pushed" }
} NavDisplay(
LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) } backStack = navigationManager.backStack,
if (destination.fullScreen) { onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) },
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize()) entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
} else { entryProvider = { destination ->
BackHandler { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) } NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) val route = when (destination) {
ShellSurface(state, modifier, handlesContentKeys = upstream == null, is Destination.Home -> "home"
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier -> is Destination.Wolfskin -> destination.route
when { else -> "pushed"
upstream != null && 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) LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) }
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier) if (destination.fullScreen) {
else -> ShellPlaceholder(route, contentModifier) DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
} else {
BackHandler { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
ShellSurface(state, handlesContentKeys = upstream == null,
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
when {
upstream != null && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
else -> ShellPlaceholder(route, contentModifier)
}
} }
} }
} }
} },
}, )
) }
} }
} }
} }

View file

@ -72,12 +72,19 @@ outputs. Editing any of them without regenerating fails the build. The shell use
the generated palette rather than the retired `WOLFSKIN` theme-enum value; the old the generated palette rather than the retired `WOLFSKIN` theme-enum value; the old
numeric value is reserved. numeric value is reserved.
Compose output maps source weights 600/650 to the platform Bold (700) face, which
resolves consistently in native captures; the approved JSON weights stay unchanged.
Nav, picker names, labels and pill weights use that same mapping. Nav label colour
fades for 200 ms ease-out; the blob retains its critically damped spring.
The small gear under the final person opens Settings with Requests focused. The small gear under the final person opens Settings with Requests focused.
Settings groups hand off to existing upstream pages; the Requests entry explicitly Settings groups hand off to existing upstream pages; the Requests entry explicitly
has no request data yet. **Advanced settings → Wolfskin screens** is the persistent has no request data yet. **Advanced settings → Wolfskin screens** is the persistent
rollout switch: turning it off renders upstream Home and library destinations, rollout switch: turning it off renders upstream Home and library destinations with
while retaining Wolfskin's nav, picker and Settings list. Turning it on renders the upstream Backdrop and top scrim, while retaining Wolfskin's nav, picker and
the labelled placeholders until the corresponding screen slices ship. Settings list. Native Up traversal stays in content until the first focusable row,
then enters the nav. Turning the switch on restores the labelled placeholders
until the corresponding screen slices ship.
Debug people, tabs and initials come from the shell-only projection of the locked Debug people, tabs and initials come from the shell-only projection of the locked
prototype fixture in `app/src/debug/assets/wolfskin-shell.json`; release resources prototype fixture in `app/src/debug/assets/wolfskin-shell.json`; release resources
@ -98,7 +105,11 @@ the container's ephemeral home. A cache miss still needs Maven Central; rate-lim
hosts can seed the existing cache from SHA512-verified local Maven artifacts without hosts can seed the existing cache from SHA512-verified local Maven artifacts without
changing the wrapper or adding volumes. changing the wrapper or adding volumes.
Branding strings and launcher artwork live in `app/src/default/res/`. Debug Default-flavour launcher artwork and English, Hungarian and Polish string overrides
live in `app/src/default/res/`. Legacy Wolfskin strings in 25 translated
`main` resource folders have not yet been moved; non-default `appstore`/`firetv`
flavours therefore combine upstream icons with some legacy Wolfskin text. Those
flavours and a full locale migration are not certified by this slice. Debug
assembly runs `checkWolfskinBranding` against every packaged APK through AGP's public assembly runs `checkWolfskinBranding` against every packaged APK through AGP's public
artifact provider and AAPT2. It checks all string, plural and array configurations, artifact provider and AAPT2. It checks all string, plural and array configurations,
rejecting user-visible upstream branding while preserving the upstream legal rejecting user-visible upstream branding while preserving the upstream legal

View file

@ -21,8 +21,13 @@ def property_name(name: str) -> str:
return name.replace("-", "_") return name.replace("-", "_")
def compose_weight(weight: int) -> str:
"""Use the platform bold face for intermediate weights that resolve to Regular in native captures."""
return "FontWeight.Bold" if 600 <= weight <= 700 else f"FontWeight({weight})"
def render_tokens(tokens: dict) -> tuple[str, str]: def render_tokens(tokens: dict) -> tuple[str, str]:
"""Translate typed source values without choosing new design values.""" """Translate locked values into platform-resolvable Compose and CSS tokens."""
lines = [ lines = [
"// Generated by generate_tokens.py; edit the JSON sources instead.", "// Generated by generate_tokens.py; edit the JSON sources instead.",
"package org.justinvisser.wolfskin.design", "package org.justinvisser.wolfskin.design",
@ -58,11 +63,11 @@ def render_tokens(tokens: dict) -> tuple[str, str]:
weight = token.get("weight", token["$value"]["fontWeight"]) weight = token.get("weight", token["$value"]["fontWeight"])
height = size * token.get("lineHeight", 1.3) height = size * token.get("lineHeight", 1.3)
tracking = token.get("letterSpacingEm", 0) tracking = token.get("letterSpacingEm", 0)
lines.append(f" val {name} = TextStyle(fontSize = {number(size)}.sp, fontWeight = FontWeight({weight}), lineHeight = {number(height)}.sp, letterSpacing = {number(tracking)}.em)") lines.append(f" val {name} = TextStyle(fontSize = {number(size)}.sp, fontWeight = {compose_weight(weight)}, lineHeight = {number(height)}.sp, letterSpacing = {number(tracking)}.em)")
css.append(f" --{group_name}-{name}: {number(token['px'])}px;") css.append(f" --{group_name}-{name}: {number(token['px'])}px;")
elif kind == "typography" and "sizes" in token: elif kind == "typography" and "sizes" in token:
for context, pixels in token["sizes"].items(): for context, pixels in token["sizes"].items():
lines.append(f" val {name}{context[0].upper() + context[1:]} = TextStyle(fontSize = {number(pixels / 2)}.sp, fontWeight = FontWeight({token['weight']}))") lines.append(f" val {name}{context[0].upper() + context[1:]} = TextStyle(fontSize = {number(pixels / 2)}.sp, fontWeight = {compose_weight(token['weight'])})")
elif kind == "duration": elif kind == "duration":
lines.append(f" const val {name} = {int(value.removesuffix('ms'))}") lines.append(f" const val {name} = {int(value.removesuffix('ms'))}")
css.append(f" --{group_name}-{name}: {value};") css.append(f" --{group_name}-{name}: {value};")

View file

@ -1,5 +1,10 @@
{ {
"shell": { "shell": {
"navLabelFade": {
"$type": "duration",
"$value": "200ms",
"source": "index.html:81; nav label color transition uses 200 ms ease-out"
},
"zero": { "zero": {
"$type": "dimension", "$type": "dimension",
"px": 0, "px": 0,

View file

@ -2,6 +2,8 @@ package org.justinvisser.wolfskin.design
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -16,10 +18,12 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.tv.material3.Text import androidx.tv.material3.Text
@ -48,6 +52,7 @@ fun navBoxes(tabs: List<String>): List<NavBox> {
@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 boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
val labelFade = remember { tween<Color>(S.navLabelFade, 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 box = boxes[index] val box = boxes[index]
val avatarFocused = state.navFocused && index == state.tabs.size val avatarFocused = state.navFocused && index == state.tabs.size
@ -73,10 +78,17 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
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
Box(Modifier.offset(item.left, item.top).size(item.width, item.height).clickable { val labelColor by animateColorAsState(
state.focusNav(itemIndex); state.key(ShellKey.Ok) if (focused) C.onFocus else if (itemIndex == state.currentTab) C.fg else C.muted,
}, contentAlignment = Alignment.Center) { animationSpec = labelFade,
Text(tab.replaceFirstChar { it.uppercase() }, style = WolfskinTokens.Type.nav, color = if (focused) C.onFocus else if (itemIndex == state.currentTab) C.fg else C.muted) label = "nav-label-$tab",
)
Box(Modifier.offset(item.left, item.top).size(item.width, item.height)
.focusProperties { canFocus = false }
.clickable {
state.focusNav(itemIndex); state.key(ShellKey.Ok)
}, contentAlignment = Alignment.Center) {
Text(tab.replaceFirstChar { it.uppercase() }, style = WolfskinTokens.Type.nav, color = labelColor)
if (itemIndex == state.currentTab && !focused) { if (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))
} }
@ -86,6 +98,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav, Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
Modifier.offset(avatar.left, avatar.top) Modifier.offset(avatar.left, avatar.top)
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg) .focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg)
.focusProperties { canFocus = false }
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) }) .clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) })
} }
} }

View file

@ -96,7 +96,7 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modi
Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon)) Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
val count = if (openRequests != null && openRequests > 0) " · $openRequests open" else "" val count = if (openRequests != null && openRequests > 0) " · $openRequests open" else ""
Text(group.title + count, color = foreground, style = WolfskinTokens.Type.listTitle.copy(fontWeight = FontWeight.Bold), maxLines = 1) Text(group.title + count, color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1)
Text(group.description, color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(group.description, color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
if (group.wolfskin) SettingsPill("Wolfskin", focused) if (group.wolfskin) SettingsPill("Wolfskin", focused)
@ -112,6 +112,6 @@ private fun SettingsPill(label: String, focused: Boolean) {
val stroke = WolfskinTokens.Focus.ringButtonRing.toPx() val stroke = WolfskinTokens.Focus.ringButtonRing.toPx()
drawRoundRect(foreground.copy(alpha = .4f), cornerRadius = CornerRadius(WolfskinTokens.Radius.pill.toPx()), style = Stroke(stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(stroke * 2, stroke * 2)))) drawRoundRect(foreground.copy(alpha = .4f), cornerRadius = CornerRadius(WolfskinTokens.Radius.pill.toPx()), style = Stroke(stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(stroke * 2, stroke * 2))))
}.padding(horizontal = S.settingsPillPadding), contentAlignment = Alignment.Center) { }.padding(horizontal = S.settingsPillPadding), contentAlignment = Alignment.Center) {
Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = FontWeight.SemiBold)) Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = WolfskinTokens.Type.label.fontWeight))
} }
} }

View file

@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
@ -24,6 +25,8 @@ import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.semantics.stateDescription
@ -57,11 +60,12 @@ fun ShellSurface(
) { ) {
val focus = remember { FocusRequester() } val focus = remember { FocusRequester() }
val contentFocus = remember { FocusRequester() } val contentFocus = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) { LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) {
if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus() if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus()
else contentFocus.requestFocus() else contentFocus.requestFocus()
} }
Box(modifier.fillMaxSize().background(C.bg).testTag("wolfskin-shell") Box(modifier.fillMaxSize().background(if (handlesContentKeys || state.pickerVisible) C.bg else Color.Transparent).testTag("wolfskin-shell")
.semantics { stateDescription = "${state.person.key}:${state.route}:${if (state.pickerVisible) "picker" else if (state.navFocused) "nav-${state.navIndex}" else "content-${state.settingsRow}"}" } .semantics { stateDescription = "${state.person.key}:${state.route}:${if (state.pickerVisible) "picker" else if (state.navFocused) "nav-${state.navIndex}" else "content-${state.settingsRow}"}" }
.focusRequester(focus) .focusRequester(focus)
.onPreviewKeyEvent { .onPreviewKeyEvent {
@ -69,7 +73,11 @@ fun ShellSurface(
} }
.onKeyEvent { .onKeyEvent {
if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) { if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) {
state.navFocused = true; state.navIndex = state.currentTab; true if (!focusManager.moveFocus(FocusDirection.Up)) {
state.navFocused = true
state.navIndex = state.currentTab
}
true
} else false } else false
}.focusable()) { }.focusable()) {
if (state.pickerVisible) { if (state.pickerVisible) {

View file

@ -65,15 +65,15 @@ object WolfskinTokens {
val sheetTitle = TextStyle(fontSize = 22.0.sp, fontWeight = FontWeight(800), lineHeight = 28.6.sp, letterSpacing = -0.01.em) val sheetTitle = TextStyle(fontSize = 22.0.sp, fontWeight = FontWeight(800), lineHeight = 28.6.sp, letterSpacing = -0.01.em)
val wordmark = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(800), lineHeight = 23.400000000000002.sp, letterSpacing = -0.02.em) val wordmark = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(800), lineHeight = 23.400000000000002.sp, letterSpacing = -0.02.em)
val stateTitle = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(400), lineHeight = 23.400000000000002.sp, letterSpacing = 0.0.em) val stateTitle = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(400), lineHeight = 23.400000000000002.sp, letterSpacing = 0.0.em)
val rowHeading = TextStyle(fontSize = 17.0.sp, fontWeight = FontWeight(700), lineHeight = 22.1.sp, letterSpacing = -0.01.em) val rowHeading = TextStyle(fontSize = 17.0.sp, fontWeight = FontWeight.Bold, lineHeight = 22.1.sp, letterSpacing = -0.01.em)
val pickerName = TextStyle(fontSize = 17.0.sp, fontWeight = FontWeight(650), lineHeight = 22.1.sp, letterSpacing = 0.0.em) val pickerName = TextStyle(fontSize = 17.0.sp, fontWeight = FontWeight.Bold, lineHeight = 22.1.sp, letterSpacing = 0.0.em)
val nav = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(600), lineHeight = 19.5.sp, letterSpacing = 0.0.em) val nav = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight.Bold, lineHeight = 19.5.sp, letterSpacing = 0.0.em)
val listTitle = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(650), lineHeight = 19.5.sp, letterSpacing = 0.0.em) val listTitle = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight.Bold, lineHeight = 19.5.sp, letterSpacing = 0.0.em)
val body = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(400), lineHeight = 18.2.sp, letterSpacing = 0.0.em) val body = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(400), lineHeight = 18.2.sp, letterSpacing = 0.0.em)
val button = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(700), lineHeight = 18.2.sp, letterSpacing = 0.0.em) val button = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 18.2.sp, letterSpacing = 0.0.em)
val label = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight(650), lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em) val label = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em)
val caption = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(400), lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em) val caption = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(400), lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
val ratingPlate = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(700), lineHeight = 16.8.sp, letterSpacing = 0.0.em) val ratingPlate = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.8.sp, letterSpacing = 0.0.em)
val avatarLetterPicker = TextStyle(fontSize = 50.0.sp, fontWeight = FontWeight(800)) val avatarLetterPicker = TextStyle(fontSize = 50.0.sp, fontWeight = FontWeight(800))
val avatarLetterNav = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800)) val avatarLetterNav = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800))
val avatarLetterSheetRow = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(800)) val avatarLetterSheetRow = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(800))
@ -230,6 +230,7 @@ object WolfskinTokens {
const val comingInVisibleRows = 5.0f const val comingInVisibleRows = 5.0f
} }
object Shell { object Shell {
const val navLabelFade = 200
val zero = 0.0.dp val zero = 0.0.dp
val stageWidth = 960.0.dp val stageWidth = 960.0.dp
val stageHeight = 540.0.dp val stageHeight = 540.0.dp

View file

@ -127,6 +127,7 @@
--motion-scroll: 180ms; --motion-scroll: 180ms;
--motion-ratingDwell: 350ms; --motion-ratingDwell: 350ms;
--motion-toast: 3200ms; --motion-toast: 3200ms;
--shell-navLabelFade: 200ms;
--shell-zero: 0.0px; --shell-zero: 0.0px;
--shell-stageWidth: 1920.0px; --shell-stageWidth: 1920.0px;
--shell-stageHeight: 1080.0px; --shell-stageHeight: 1080.0px;

View file

@ -1,6 +1,6 @@
d98e9ff63dc1010e7af362b36ff3dce7f4052d7de7da405c1ed6c06191a6205f discover-tokens.json d98e9ff63dc1010e7af362b36ff3dce7f4052d7de7da405c1ed6c06191a6205f discover-tokens.json
1219bff60f4aeaa608e3ca82dcdeb82d0f1b5d402a31c7cb8d6b3d322bb2b698 generate_tokens.py fcff646d59fb39e859c34c05037e05a7fb7583de2fe07315ec0176cf4d597980 generate_tokens.py
a066eaaa68e01e6235ffcd525818f0cb20b44e0aed1687cb645e528d8947aa5d shell-tokens.json 6d1d19656b40e6a442cb548bb238bb12244adb6b3b717ed9e564af61119559c8 shell-tokens.json
309cd509e8a632e504cb4bbd9d8d7ac05273e25c3525295f7aae557d2e94160c src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt 54e91e53f3c6e928aac6f4419db2bdde458957b57e2271aef45c6d521ad6162e src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
03961305541d5d6cae69c4a5889337b8995918b579f963aee431349d890b5791 tokens.css fb3018e251c2dd795e0ed522a331da307ab6477a3fd45b246d1cb7fd514dc069 tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json