diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt new file mode 100644 index 00000000..78690396 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt @@ -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() + + @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(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) + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index d4db2ace..700d0b9d 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -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 = backdrop.backdropFlow var libraryDestinations by mutableStateOf>(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) + } } } } - } - }, - ) + }, + ) + } } } } diff --git a/docker/README.md b/docker/README.md index 8982dfd3..a00dbe66 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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 diff --git a/wolfskin-design/generate_tokens.py b/wolfskin-design/generate_tokens.py index 3e51a059..98f2c88f 100755 --- a/wolfskin-design/generate_tokens.py +++ b/wolfskin-design/generate_tokens.py @@ -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};") diff --git a/wolfskin-design/shell-tokens.json b/wolfskin-design/shell-tokens.json index c18fc5d7..d5e0c688 100644 --- a/wolfskin-design/shell-tokens.json +++ b/wolfskin-design/shell-tokens.json @@ -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, diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt index 4b3bc0ff..1a3f1ef8 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt @@ -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): List { @Composable fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) { val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) } + val labelFade = remember { tween(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) }) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt index 2963c53b..9823c319 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt @@ -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)) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt index 7fea90b8..7132111f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt @@ -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) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index deda47d6..d38a7dc0 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -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 diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index ae81c3ce..f10baeed 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -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; diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index fae39054..795e6f8d 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -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