Compare commits
2 commits
9be55c570a
...
2ea9301069
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ea9301069 | |||
| 4b89a73908 |
11 changed files with 254 additions and 52 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,35 +156,46 @@ 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.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.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
|
||||
else -> ShellPlaceholder(route, contentModifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
the labelled placeholders until the corresponding screen slices ship.
|
||||
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 restores the labelled placeholders
|
||||
until the corresponding screen slices ship.
|
||||
|
||||
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
|
||||
|
|
@ -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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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};")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -16,10 +18,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
|
||||
|
|
@ -48,6 +52,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
|
||||
|
|
@ -73,10 +78,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))
|
||||
}
|
||||
|
|
@ -86,6 +98,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) })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
252ce7eb35cc07ef2661cba5cec6c2046e0207c17584a5d83ffe038a365e7cdb generate_tokens.py
|
||||
a066eaaa68e01e6235ffcd525818f0cb20b44e0aed1687cb645e528d8947aa5d shell-tokens.json
|
||||
d7cc75e1643ebf79204fd5df94e03ad182f7451761d48ca54da140616dd957bc src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
bb99f1f3b51028bbe87b245b4a78a59e55d4b519bc947afe56e5db11406db092 tokens.css
|
||||
1438f7ca4a2b6f951c9e643ab0afacbf2f7fc98e57905295d518c6225e054de4 generate_tokens.py
|
||||
6d1d19656b40e6a442cb548bb238bb12244adb6b3b717ed9e564af61119559c8 shell-tokens.json
|
||||
58131daf2bf889dcc2cf2a55f4a179fd6ad9bafa2da798b9a49a65ae3d58ae71 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
9143548adf883d934ee86d5d4dd140a9a2d8116c6c8a905a41d2bf7d828bc371 tokens.css
|
||||
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue