Merge the reviewed shell updates

This commit is contained in:
justinzeus 2026-11-05 16:17:38 +01:00
commit c07bb2ca1d
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 androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@ -15,6 +16,7 @@ import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry
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.JellyfinUser
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.NavigationManager
import com.github.damontecres.wholphin.services.SetupDestination
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.DestinationContent
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.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.justinvisser.wolfskin.design.PeopleStatus
@ -66,6 +71,7 @@ class WolfskinShellViewModel @Inject constructor(
private val backdrop: BackdropService,
private val libraries: LibraryReadFacade,
) : ViewModel() {
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
var libraryDestinations by mutableStateOf<Map<String, Destination>>(emptyMap())
private set
var signedInPerson by mutableStateOf("")
@ -150,36 +156,47 @@ fun WolfskinApp(
val state = viewModel.session.state ?: loading
WolfskinTheme {
CompositionLocalProvider(LocalShellState provides state) {
NavDisplay(
backStack = navigationManager.backStack,
onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) },
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
entryProvider = { destination ->
NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
val route = when (destination) {
is Destination.Home -> "home"
is Destination.Wolfskin -> destination.route
else -> "pushed"
}
LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) }
if (destination.fullScreen) {
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, modifier, handlesContentKeys = upstream == null) { 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.Home -> HomeDestination(state, server, user, contentModifier)
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
else -> ShellPlaceholder(route, contentModifier)
Box(modifier) {
val current = navigationManager.backStack.last()
if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) {
val backdrop by viewModel.backdropFlow.collectAsStateWithLifecycle()
Backdrop(
backdrop = backdrop,
drawerIsOpen = false,
backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle,
)
}
NavDisplay(
backStack = navigationManager.backStack,
onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) },
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
entryProvider = { destination ->
NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
val route = when (destination) {
is Destination.Home -> "home"
is Destination.Wolfskin -> destination.route
else -> "pushed"
}
LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) }
if (destination.fullScreen) {
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) { 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.Home -> HomeDestination(state, server, user, contentModifier)
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
else -> ShellPlaceholder(route, contentModifier)
}
}
}
}
}
},
)
},
)
}
}
}
}

View file

@ -73,12 +73,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
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.
Settings groups hand off to existing upstream pages; the Requests entry explicitly
has no request data yet. **Advanced settings → Wolfskin screens** is the persistent
rollout switch: turning it off renders upstream Home and library destinations,
while retaining Wolfskin's nav, picker and Settings list. Turning it on renders
Wolfskin Home; later-slice screens remain explicitly labelled navigation targets.
rollout switch: turning it off renders upstream Home and library destinations with
the upstream Backdrop and top scrim, while retaining Wolfskin's nav, picker and
Settings list. Native Up traversal stays in content until the first focusable row,
then enters the nav. Turning the switch on renders Wolfskin Home; later-slice
screens remain explicitly labelled navigation targets.
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
@ -99,7 +106,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
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
artifact provider and AAPT2. It checks all string, plural and array configurations,
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("-", "_")
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]:
"""Translate typed source values without choosing new design values."""
"""Translate locked values into platform-resolvable Compose and CSS tokens."""
lines = [
"// Generated by generate_tokens.py; edit the JSON sources instead.",
"package org.justinvisser.wolfskin.design",
@ -58,11 +63,11 @@ def render_tokens(tokens: dict) -> tuple[str, str]:
weight = token.get("weight", token["$value"]["fontWeight"])
height = size * token.get("lineHeight", 1.3)
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;")
elif kind == "typography" and "sizes" in token:
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":
lines.append(f" const val {name} = {int(value.removesuffix('ms'))}")
css.append(f" --{group_name}-{name}: {value};")

View file

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

View file

@ -2,6 +2,8 @@ package org.justinvisser.wolfskin.design
import androidx.compose.animation.animateColorAsState
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.foundation.Canvas
import androidx.compose.foundation.background
@ -21,10 +23,12 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusProperties
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.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.tv.material3.Text
@ -53,6 +57,7 @@ fun navBoxes(tabs: List<String>): List<NavBox> {
@Composable
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
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 box = boxes[index]
val avatarFocused = state.navFocused && index == state.tabs.size
@ -78,10 +83,17 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
state.tabs.forEachIndexed { itemIndex, tab ->
val item = boxes[itemIndex]
val focused = state.navFocused && state.navIndex == itemIndex
Box(Modifier.offset(item.left, item.top).size(item.width, item.height).clickable {
state.focusNav(itemIndex); state.key(ShellKey.Ok)
}, contentAlignment = Alignment.Center) {
Text(tab.replaceFirstChar { it.uppercase() }, style = WolfskinTokens.Type.nav, color = if (focused) C.onFocus else if (itemIndex == state.currentTab) C.fg else C.muted)
val labelColor by animateColorAsState(
if (focused) C.onFocus else if (itemIndex == state.currentTab) C.fg else C.muted,
animationSpec = labelFade,
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) {
Box(Modifier.align(Alignment.BottomCenter).offset(y = -L.navUnderline).size(item.width - L.navItemPaddingX * 2, L.navUnderline).background(C.accent))
}
@ -99,6 +111,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
Modifier.offset(avatar.left, avatar.top)
.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) })
}
}

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))
Column(Modifier.weight(1f)) {
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)
}
if (group.wolfskin) SettingsPill("Wolfskin", focused)
@ -112,6 +112,6 @@ private fun SettingsPill(label: String, focused: Boolean) {
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))))
}.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.remember
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.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.onPreviewKeyEvent
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.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
@ -56,11 +59,12 @@ fun ShellSurface(
) {
val focus = remember { FocusRequester() }
val contentFocus = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) {
if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.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}"}" }
.focusRequester(focus)
.onPreviewKeyEvent {
@ -68,7 +72,11 @@ fun ShellSurface(
}
.onKeyEvent {
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
}.focusable()) {
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 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 rowHeading = TextStyle(fontSize = 17.0.sp, fontWeight = FontWeight(700), 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 nav = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(600), 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 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.Bold, lineHeight = 22.1.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.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 button = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(700), 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 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.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 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 avatarLetterNav = TextStyle(fontSize = 15.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
}
object Shell {
const val navLabelFade = 200
val zero = 0.0.dp
val stageWidth = 960.0.dp
val stageHeight = 540.0.dp

View file

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

View file

@ -1,6 +1,6 @@
e3ceb0c3b80797a163a4a439cbf86d747e2d6562d559eb1e04709fc0ed6b3c7e generate_tokens.py
28f6f7bb878b8388ce04293cd2819bc1cb684da34925c9ddcded78fcddf6d1dd generate_tokens.py
77a489b428b038a874624c9d7d881e69b9f20ca478961743061b76ce31b7bd31 home-tokens.json
a066eaaa68e01e6235ffcd525818f0cb20b44e0aed1687cb645e528d8947aa5d shell-tokens.json
e65d665e1ac2aad070eea70c68b9234c7a7371636ac8e55af8a45b96b2f42302 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
1f44bdf41eabde0061cd9c6608c77f8c64ca435362e4760f1d11f15a57487e36 tokens.css
6d1d19656b40e6a442cb548bb238bb12244adb6b3b717ed9e564af61119559c8 shell-tokens.json
b051a021d1fc9bb7d03287891f4a7eb62e4eaee4058d54e13bbff108924c6fa0 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
853f22d6f2572f55a2c8a0cac7e5ba11647d3d70e1b2c0be70d23cf06c7f2e58 tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json