Compare commits
No commits in common. "2ea9301069d7d2adc5a956487d83ec284897fafe" and "9be55c570afd863b858484bc3b1b47a8a90dc678" have entirely different histories.
2ea9301069
...
9be55c570a
11 changed files with 52 additions and 254 deletions
|
|
@ -1,141 +0,0 @@
|
||||||
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,7 +2,6 @@ package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
|
@ -16,7 +15,6 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import androidx.navigation3.runtime.NavEntry
|
import androidx.navigation3.runtime.NavEntry
|
||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
|
|
@ -25,12 +23,10 @@ import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.BackdropResult
|
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.SetupDestination
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.nav.Backdrop
|
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.nav.DestinationContent
|
import com.github.damontecres.wholphin.ui.nav.DestinationContent
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
||||||
|
|
@ -39,7 +35,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.justinvisser.wolfskin.design.PeopleStatus
|
import org.justinvisser.wolfskin.design.PeopleStatus
|
||||||
|
|
@ -71,7 +66,6 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
private val backdrop: BackdropService,
|
private val backdrop: BackdropService,
|
||||||
private val libraries: LibraryReadFacade,
|
private val libraries: LibraryReadFacade,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
|
|
||||||
var libraryDestinations by mutableStateOf<Map<String, Destination>>(emptyMap())
|
var libraryDestinations by mutableStateOf<Map<String, Destination>>(emptyMap())
|
||||||
private set
|
private set
|
||||||
var signedInPerson by mutableStateOf("")
|
var signedInPerson by mutableStateOf("")
|
||||||
|
|
@ -156,16 +150,6 @@ fun WolfskinApp(
|
||||||
val state = viewModel.session.state ?: loading
|
val state = viewModel.session.state ?: loading
|
||||||
WolfskinTheme {
|
WolfskinTheme {
|
||||||
CompositionLocalProvider(LocalShellState provides state) {
|
CompositionLocalProvider(LocalShellState provides state) {
|
||||||
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(
|
NavDisplay(
|
||||||
backStack = navigationManager.backStack,
|
backStack = navigationManager.backStack,
|
||||||
onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) },
|
onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) },
|
||||||
|
|
@ -183,7 +167,7 @@ fun WolfskinApp(
|
||||||
} else {
|
} else {
|
||||||
BackHandler { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
|
BackHandler { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
|
||||||
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
|
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
|
||||||
ShellSurface(state, handlesContentKeys = upstream == null) { contentModifier ->
|
ShellSurface(state, modifier, handlesContentKeys = upstream == null) { contentModifier ->
|
||||||
when {
|
when {
|
||||||
upstream != null && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
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)
|
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
|
||||||
|
|
@ -197,7 +181,6 @@ fun WolfskinApp(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The single registered destination branch delegates Wolfskin-only screens back to the shell. */
|
/** The single registered destination branch delegates Wolfskin-only screens back to the shell. */
|
||||||
|
|
|
||||||
|
|
@ -72,19 +72,12 @@ outputs. Editing any of them without regenerating fails the build. The shell use
|
||||||
the generated palette rather than the retired `WOLFSKIN` theme-enum value; the old
|
the generated palette rather than the retired `WOLFSKIN` theme-enum value; the old
|
||||||
numeric value is reserved.
|
numeric value is reserved.
|
||||||
|
|
||||||
Compose output maps source weights 600/650 to the platform Bold (700) face, which
|
|
||||||
resolves consistently in native captures; the approved JSON weights stay unchanged.
|
|
||||||
Nav, picker names, labels and pill weights use that same mapping. Nav label colour
|
|
||||||
fades for 200 ms ease-out; the blob retains its critically damped spring.
|
|
||||||
|
|
||||||
The small gear under the final person opens Settings with Requests focused.
|
The small gear under the final person opens Settings with Requests focused.
|
||||||
Settings groups hand off to existing upstream pages; the Requests entry explicitly
|
Settings groups hand off to existing upstream pages; the Requests entry explicitly
|
||||||
has no request data yet. **Advanced settings → Wolfskin screens** is the persistent
|
has no request data yet. **Advanced settings → Wolfskin screens** is the persistent
|
||||||
rollout switch: turning it off renders upstream Home and library destinations with
|
rollout switch: turning it off renders upstream Home and library destinations,
|
||||||
the upstream Backdrop and top scrim, while retaining Wolfskin's nav, picker and
|
while retaining Wolfskin's nav, picker and Settings list. Turning it on renders
|
||||||
Settings list. Native Up traversal stays in content until the first focusable row,
|
the labelled placeholders until the corresponding screen slices ship.
|
||||||
then enters the nav. Turning the switch on restores the labelled placeholders
|
|
||||||
until the corresponding screen slices ship.
|
|
||||||
|
|
||||||
Debug people, tabs and initials come from the shell-only projection of the locked
|
Debug people, tabs and initials come from the shell-only projection of the locked
|
||||||
prototype fixture in `app/src/debug/assets/wolfskin-shell.json`; release resources
|
prototype fixture in `app/src/debug/assets/wolfskin-shell.json`; release resources
|
||||||
|
|
@ -105,11 +98,7 @@ the container's ephemeral home. A cache miss still needs Maven Central; rate-lim
|
||||||
hosts can seed the existing cache from SHA512-verified local Maven artifacts without
|
hosts can seed the existing cache from SHA512-verified local Maven artifacts without
|
||||||
changing the wrapper or adding volumes.
|
changing the wrapper or adding volumes.
|
||||||
|
|
||||||
Default-flavour launcher artwork and English, Hungarian and Polish string overrides
|
Branding strings and launcher artwork live in `app/src/default/res/`. Debug
|
||||||
live in `app/src/default/res/`. Legacy Wolfskin strings in 25 translated
|
|
||||||
`main` resource folders have not yet been moved; non-default `appstore`/`firetv`
|
|
||||||
flavours therefore combine upstream icons with some legacy Wolfskin text. Those
|
|
||||||
flavours and a full locale migration are not certified by this slice. Debug
|
|
||||||
assembly runs `checkWolfskinBranding` against every packaged APK through AGP's public
|
assembly runs `checkWolfskinBranding` against every packaged APK through AGP's public
|
||||||
artifact provider and AAPT2. It checks all string, plural and array configurations,
|
artifact provider and AAPT2. It checks all string, plural and array configurations,
|
||||||
rejecting user-visible upstream branding while preserving the upstream legal
|
rejecting user-visible upstream branding while preserving the upstream legal
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,8 @@ def property_name(name: str) -> str:
|
||||||
return name.replace("-", "_")
|
return name.replace("-", "_")
|
||||||
|
|
||||||
|
|
||||||
def compose_weight(weight: int) -> str:
|
|
||||||
"""Use the platform bold face for intermediate weights that resolve to Regular in native captures."""
|
|
||||||
return "FontWeight.Bold" if 600 <= weight <= 700 else f"FontWeight({weight})"
|
|
||||||
|
|
||||||
|
|
||||||
def render_tokens(tokens: dict) -> tuple[str, str]:
|
def render_tokens(tokens: dict) -> tuple[str, str]:
|
||||||
"""Translate locked values into platform-resolvable Compose and CSS tokens."""
|
"""Translate typed source values without choosing new design values."""
|
||||||
lines = [
|
lines = [
|
||||||
"// Generated by generate_tokens.py; edit the JSON sources instead.",
|
"// Generated by generate_tokens.py; edit the JSON sources instead.",
|
||||||
"package org.justinvisser.wolfskin.design",
|
"package org.justinvisser.wolfskin.design",
|
||||||
|
|
@ -63,11 +58,11 @@ def render_tokens(tokens: dict) -> tuple[str, str]:
|
||||||
weight = token.get("weight", token["$value"]["fontWeight"])
|
weight = token.get("weight", token["$value"]["fontWeight"])
|
||||||
height = size * token.get("lineHeight", 1.3)
|
height = size * token.get("lineHeight", 1.3)
|
||||||
tracking = token.get("letterSpacingEm", 0)
|
tracking = token.get("letterSpacingEm", 0)
|
||||||
lines.append(f" val {name} = TextStyle(fontSize = {number(size)}.sp, fontWeight = {compose_weight(weight)}, lineHeight = {number(height)}.sp, letterSpacing = {number(tracking)}.em)")
|
lines.append(f" val {name} = TextStyle(fontSize = {number(size)}.sp, fontWeight = FontWeight({weight}), lineHeight = {number(height)}.sp, letterSpacing = {number(tracking)}.em)")
|
||||||
css.append(f" --{group_name}-{name}: {number(token['px'])}px;")
|
css.append(f" --{group_name}-{name}: {number(token['px'])}px;")
|
||||||
elif kind == "typography" and "sizes" in token:
|
elif kind == "typography" and "sizes" in token:
|
||||||
for context, pixels in token["sizes"].items():
|
for context, pixels in token["sizes"].items():
|
||||||
lines.append(f" val {name}{context[0].upper() + context[1:]} = TextStyle(fontSize = {number(pixels / 2)}.sp, fontWeight = {compose_weight(token['weight'])})")
|
lines.append(f" val {name}{context[0].upper() + context[1:]} = TextStyle(fontSize = {number(pixels / 2)}.sp, fontWeight = FontWeight({token['weight']}))")
|
||||||
elif kind == "duration":
|
elif kind == "duration":
|
||||||
lines.append(f" const val {name} = {int(value.removesuffix('ms'))}")
|
lines.append(f" const val {name} = {int(value.removesuffix('ms'))}")
|
||||||
css.append(f" --{group_name}-{name}: {value};")
|
css.append(f" --{group_name}-{name}: {value};")
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
{
|
{
|
||||||
"shell": {
|
"shell": {
|
||||||
"navLabelFade": {
|
|
||||||
"$type": "duration",
|
|
||||||
"$value": "200ms",
|
|
||||||
"source": "index.html:81; nav label color transition uses 200 ms ease-out"
|
|
||||||
},
|
|
||||||
"zero": {
|
"zero": {
|
||||||
"$type": "dimension",
|
"$type": "dimension",
|
||||||
"px": 0,
|
"px": 0,
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@ package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.CubicBezierEasing
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
|
@ -18,12 +16,10 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.focusProperties
|
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
|
@ -52,7 +48,6 @@ fun navBoxes(tabs: List<String>): List<NavBox> {
|
||||||
@Composable
|
@Composable
|
||||||
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
|
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
|
||||||
val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
|
val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
|
||||||
val labelFade = remember { tween<Color>(S.navLabelFade, easing = CubicBezierEasing(0f, 0f, .58f, 1f)) }
|
|
||||||
val index = if (state.navFocused) state.navIndex else state.currentTab
|
val index = if (state.navFocused) state.navIndex else state.currentTab
|
||||||
val box = boxes[index]
|
val box = boxes[index]
|
||||||
val avatarFocused = state.navFocused && index == state.tabs.size
|
val avatarFocused = state.navFocused && index == state.tabs.size
|
||||||
|
|
@ -78,17 +73,10 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
|
||||||
state.tabs.forEachIndexed { itemIndex, tab ->
|
state.tabs.forEachIndexed { itemIndex, tab ->
|
||||||
val item = boxes[itemIndex]
|
val item = boxes[itemIndex]
|
||||||
val focused = state.navFocused && state.navIndex == itemIndex
|
val focused = state.navFocused && state.navIndex == itemIndex
|
||||||
val labelColor by animateColorAsState(
|
Box(Modifier.offset(item.left, item.top).size(item.width, item.height).clickable {
|
||||||
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)
|
state.focusNav(itemIndex); state.key(ShellKey.Ok)
|
||||||
}, contentAlignment = Alignment.Center) {
|
}, contentAlignment = Alignment.Center) {
|
||||||
Text(tab.replaceFirstChar { it.uppercase() }, style = WolfskinTokens.Type.nav, color = labelColor)
|
Text(tab.replaceFirstChar { it.uppercase() }, style = WolfskinTokens.Type.nav, color = if (focused) C.onFocus else if (itemIndex == state.currentTab) C.fg else C.muted)
|
||||||
if (itemIndex == state.currentTab && !focused) {
|
if (itemIndex == state.currentTab && !focused) {
|
||||||
Box(Modifier.align(Alignment.BottomCenter).offset(y = -L.navUnderline).size(item.width - L.navItemPaddingX * 2, L.navUnderline).background(C.accent))
|
Box(Modifier.align(Alignment.BottomCenter).offset(y = -L.navUnderline).size(item.width - L.navItemPaddingX * 2, L.navUnderline).background(C.accent))
|
||||||
}
|
}
|
||||||
|
|
@ -98,7 +86,6 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
|
||||||
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
|
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
|
||||||
Modifier.offset(avatar.left, avatar.top)
|
Modifier.offset(avatar.left, avatar.top)
|
||||||
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg)
|
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg)
|
||||||
.focusProperties { canFocus = false }
|
|
||||||
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) })
|
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modi
|
||||||
Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
|
Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
val count = if (openRequests != null && openRequests > 0) " · $openRequests open" else ""
|
val count = if (openRequests != null && openRequests > 0) " · $openRequests open" else ""
|
||||||
Text(group.title + count, color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1)
|
Text(group.title + count, color = foreground, style = WolfskinTokens.Type.listTitle.copy(fontWeight = FontWeight.Bold), maxLines = 1)
|
||||||
Text(group.description, color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(group.description, color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
}
|
}
|
||||||
if (group.wolfskin) SettingsPill("Wolfskin", focused)
|
if (group.wolfskin) SettingsPill("Wolfskin", focused)
|
||||||
|
|
@ -112,6 +112,6 @@ private fun SettingsPill(label: String, focused: Boolean) {
|
||||||
val stroke = WolfskinTokens.Focus.ringButtonRing.toPx()
|
val stroke = WolfskinTokens.Focus.ringButtonRing.toPx()
|
||||||
drawRoundRect(foreground.copy(alpha = .4f), cornerRadius = CornerRadius(WolfskinTokens.Radius.pill.toPx()), style = Stroke(stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(stroke * 2, stroke * 2))))
|
drawRoundRect(foreground.copy(alpha = .4f), cornerRadius = CornerRadius(WolfskinTokens.Radius.pill.toPx()), style = Stroke(stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(stroke * 2, stroke * 2))))
|
||||||
}.padding(horizontal = S.settingsPillPadding), contentAlignment = Alignment.Center) {
|
}.padding(horizontal = S.settingsPillPadding), contentAlignment = Alignment.Center) {
|
||||||
Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = WolfskinTokens.Type.label.fontWeight))
|
Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = FontWeight.SemiBold))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusDirection
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
|
|
@ -25,8 +24,6 @@ import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.onKeyEvent
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.semantics.stateDescription
|
import androidx.compose.ui.semantics.stateDescription
|
||||||
|
|
@ -59,12 +56,11 @@ fun ShellSurface(
|
||||||
) {
|
) {
|
||||||
val focus = remember { FocusRequester() }
|
val focus = remember { FocusRequester() }
|
||||||
val contentFocus = remember { FocusRequester() }
|
val contentFocus = remember { FocusRequester() }
|
||||||
val focusManager = LocalFocusManager.current
|
|
||||||
LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) {
|
LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) {
|
||||||
if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus()
|
if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus()
|
||||||
else contentFocus.requestFocus()
|
else contentFocus.requestFocus()
|
||||||
}
|
}
|
||||||
Box(modifier.fillMaxSize().background(if (handlesContentKeys || state.pickerVisible) C.bg else Color.Transparent).testTag("wolfskin-shell")
|
Box(modifier.fillMaxSize().background(C.bg).testTag("wolfskin-shell")
|
||||||
.semantics { stateDescription = "${state.person.key}:${state.route}:${if (state.pickerVisible) "picker" else if (state.navFocused) "nav-${state.navIndex}" else "content-${state.settingsRow}"}" }
|
.semantics { stateDescription = "${state.person.key}:${state.route}:${if (state.pickerVisible) "picker" else if (state.navFocused) "nav-${state.navIndex}" else "content-${state.settingsRow}"}" }
|
||||||
.focusRequester(focus)
|
.focusRequester(focus)
|
||||||
.onPreviewKeyEvent {
|
.onPreviewKeyEvent {
|
||||||
|
|
@ -72,11 +68,7 @@ fun ShellSurface(
|
||||||
}
|
}
|
||||||
.onKeyEvent {
|
.onKeyEvent {
|
||||||
if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) {
|
if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) {
|
||||||
if (!focusManager.moveFocus(FocusDirection.Up)) {
|
state.navFocused = true; state.navIndex = state.currentTab; true
|
||||||
state.navFocused = true
|
|
||||||
state.navIndex = state.currentTab
|
|
||||||
}
|
|
||||||
true
|
|
||||||
} else false
|
} else false
|
||||||
}.focusable()) {
|
}.focusable()) {
|
||||||
if (state.pickerVisible) {
|
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 sheetTitle = TextStyle(fontSize = 22.0.sp, fontWeight = FontWeight(800), lineHeight = 28.6.sp, letterSpacing = -0.01.em)
|
||||||
val wordmark = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(800), lineHeight = 23.400000000000002.sp, letterSpacing = -0.02.em)
|
val wordmark = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(800), lineHeight = 23.400000000000002.sp, letterSpacing = -0.02.em)
|
||||||
val stateTitle = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(400), lineHeight = 23.400000000000002.sp, letterSpacing = 0.0.em)
|
val stateTitle = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(400), lineHeight = 23.400000000000002.sp, letterSpacing = 0.0.em)
|
||||||
val rowHeading = TextStyle(fontSize = 17.0.sp, fontWeight = FontWeight.Bold, lineHeight = 22.1.sp, letterSpacing = -0.01.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.Bold, lineHeight = 22.1.sp, letterSpacing = 0.0.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.Bold, lineHeight = 19.5.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.Bold, lineHeight = 19.5.sp, letterSpacing = 0.0.em)
|
val listTitle = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(650), lineHeight = 19.5.sp, letterSpacing = 0.0.em)
|
||||||
val body = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(400), lineHeight = 18.2.sp, letterSpacing = 0.0.em)
|
val body = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(400), lineHeight = 18.2.sp, letterSpacing = 0.0.em)
|
||||||
val button = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, 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.Bold, lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em)
|
val label = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight(650), lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em)
|
||||||
val caption = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(400), lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
|
val caption = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(400), lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
|
||||||
val ratingPlate = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.8.sp, letterSpacing = 0.0.em)
|
val ratingPlate = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(700), lineHeight = 16.8.sp, letterSpacing = 0.0.em)
|
||||||
val avatarLetterPicker = TextStyle(fontSize = 50.0.sp, fontWeight = FontWeight(800))
|
val avatarLetterPicker = TextStyle(fontSize = 50.0.sp, fontWeight = FontWeight(800))
|
||||||
val avatarLetterNav = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800))
|
val avatarLetterNav = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800))
|
||||||
val avatarLetterSheetRow = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(800))
|
val avatarLetterSheetRow = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(800))
|
||||||
|
|
@ -230,7 +230,6 @@ object WolfskinTokens {
|
||||||
const val comingInVisibleRows = 5.0f
|
const val comingInVisibleRows = 5.0f
|
||||||
}
|
}
|
||||||
object Shell {
|
object Shell {
|
||||||
const val navLabelFade = 200
|
|
||||||
val zero = 0.0.dp
|
val zero = 0.0.dp
|
||||||
val stageWidth = 960.0.dp
|
val stageWidth = 960.0.dp
|
||||||
val stageHeight = 540.0.dp
|
val stageHeight = 540.0.dp
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,6 @@
|
||||||
--motion-scroll: 180ms;
|
--motion-scroll: 180ms;
|
||||||
--motion-ratingDwell: 350ms;
|
--motion-ratingDwell: 350ms;
|
||||||
--motion-toast: 3200ms;
|
--motion-toast: 3200ms;
|
||||||
--shell-navLabelFade: 200ms;
|
|
||||||
--shell-zero: 0.0px;
|
--shell-zero: 0.0px;
|
||||||
--shell-stageWidth: 1920.0px;
|
--shell-stageWidth: 1920.0px;
|
||||||
--shell-stageHeight: 1080.0px;
|
--shell-stageHeight: 1080.0px;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
1438f7ca4a2b6f951c9e643ab0afacbf2f7fc98e57905295d518c6225e054de4 generate_tokens.py
|
252ce7eb35cc07ef2661cba5cec6c2046e0207c17584a5d83ffe038a365e7cdb generate_tokens.py
|
||||||
6d1d19656b40e6a442cb548bb238bb12244adb6b3b717ed9e564af61119559c8 shell-tokens.json
|
a066eaaa68e01e6235ffcd525818f0cb20b44e0aed1687cb645e528d8947aa5d shell-tokens.json
|
||||||
58131daf2bf889dcc2cf2a55f4a179fd6ad9bafa2da798b9a49a65ae3d58ae71 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
d7cc75e1643ebf79204fd5df94e03ad182f7451761d48ca54da140616dd957bc src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||||
9143548adf883d934ee86d5d4dd140a9a2d8116c6c8a905a41d2bf7d828bc371 tokens.css
|
bb99f1f3b51028bbe87b245b4a78a59e55d4b519bc947afe56e5db11406db092 tokens.css
|
||||||
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
|
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue