Match Discover shadows, text rhythm and asynchronous focus
Some checks failed
review/design-claude Changes requested: 3 blocking design deviations (st-032 sheet rhythm, st-036 button squeeze, hero stack offset)
Some checks failed
review/design-claude Changes requested: 3 blocking design deviations (st-032 sheet rhythm, st-036 button squeeze, hero stack offset)
This commit is contained in:
parent
4e62aad638
commit
1299034991
21 changed files with 293 additions and 80 deletions
|
|
@ -0,0 +1,101 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.DiscoverFixture
|
||||
import org.justinvisser.wolfskin.design.DiscoverScreen
|
||||
import org.justinvisser.wolfskin.design.DiscoverState
|
||||
import org.justinvisser.wolfskin.design.DiscoverStatus
|
||||
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 real asynchronous host must return focus from its loading nav to the first loaded action. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class DiscoverLoadingFocusTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun firstOkAfterLoadingOpensTheHeroInsteadOfSelectingTheNav() {
|
||||
val pending = CompletableDeferred<DiscoverFixture>()
|
||||
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply {
|
||||
pickerVisible = false
|
||||
enterDestination("discover")
|
||||
}
|
||||
lateinit var current: DiscoverState
|
||||
rule.setContent {
|
||||
val state = rememberDiscoverState(shell, shell::enterDestination, { shell.enterDestination("discover") }, loadFixture = { pending.await() })
|
||||
current = state
|
||||
WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } }
|
||||
}
|
||||
rule.runOnIdle {
|
||||
assertEquals(DiscoverStatus.Loading, current.snapshot.status)
|
||||
assertTrue(shell.navFocused)
|
||||
val json = rule.activity.assets.open("wolfskin-discover/fixture.json").bufferedReader().use { it.readText() }
|
||||
pending.complete(DiscoverFixture(json) { "file:///android_asset/wolfskin-discover/$it" })
|
||||
}
|
||||
rule.waitForIdle()
|
||||
rule.runOnIdle {
|
||||
assertEquals(DiscoverStatus.Ready, current.snapshot.status)
|
||||
assertFalse(shell.navFocused)
|
||||
assertEquals(0, current.focusedRow)
|
||||
}
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
|
||||
rule.runOnIdle {
|
||||
assertNotNull(current.title)
|
||||
assertEquals("The Amazing Spider-Man", current.title!!.title)
|
||||
assertEquals("discover-title/${current.title!!.key}", shell.route)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun finishingLoadPreservesANavTargetChosenWhileLoading() {
|
||||
val pending = CompletableDeferred<DiscoverFixture>()
|
||||
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply {
|
||||
pickerVisible = false
|
||||
enterDestination("discover")
|
||||
}
|
||||
lateinit var current: DiscoverState
|
||||
rule.setContent {
|
||||
val state = rememberDiscoverState(shell, shell::enterDestination, { shell.enterDestination("discover") }, loadFixture = { pending.await() })
|
||||
current = state
|
||||
WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } }
|
||||
}
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight) }
|
||||
rule.runOnIdle {
|
||||
assertEquals(DiscoverStatus.Loading, current.snapshot.status)
|
||||
assertEquals(2, shell.navIndex)
|
||||
val json = rule.activity.assets.open("wolfskin-discover/fixture.json").bufferedReader().use { it.readText() }
|
||||
pending.complete(DiscoverFixture(json) { "file:///android_asset/wolfskin-discover/$it" })
|
||||
}
|
||||
rule.waitForIdle()
|
||||
rule.runOnIdle {
|
||||
assertEquals(DiscoverStatus.Ready, current.snapshot.status)
|
||||
assertTrue(shell.navFocused)
|
||||
assertEquals(2, shell.navIndex)
|
||||
}
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
|
||||
rule.runOnIdle {
|
||||
assertEquals("movies", shell.route)
|
||||
assertEquals(null, current.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -29,15 +29,23 @@ internal val LocalDiscoverState = compositionLocalOf<DiscoverState> { error("Dis
|
|||
|
||||
/** No client or endpoint is constructed before TV sign-in exists; release stays explicitly signed out. */
|
||||
@Composable
|
||||
internal fun rememberDiscoverState(shell: ShellState, push: (String) -> Unit, pop: () -> Unit): DiscoverState {
|
||||
internal fun rememberDiscoverState(
|
||||
shell: ShellState,
|
||||
push: (String) -> Unit,
|
||||
pop: () -> Unit,
|
||||
loadFixture: suspend (Context) -> DiscoverFixture = { context -> withContext(Dispatchers.IO) { readDiscoverFixture(context) } },
|
||||
): DiscoverState {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var state by remember(shell, shell.person.key) { mutableStateOf(DiscoverState(shell, DiscoverSnapshot(status = if (BuildConfig.DEBUG) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn), SignedOutDiscoverOperations, scope, push, pop)) }
|
||||
LaunchedEffect(shell, shell.person.key) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
val fixture = withContext(Dispatchers.IO) { readDiscoverFixture(context) }
|
||||
val fixture = loadFixture(context)
|
||||
val snapshot = fixture.snapshot(shell.person.key)
|
||||
state = DiscoverState(shell, snapshot, DebugDiscoverOperations(snapshot, shell.person.name), scope, push, pop, fixture::service)
|
||||
val resumeContent = shell.route == "discover" && (!shell.navFocused || shell.navIndex == shell.currentTab)
|
||||
val loaded = DiscoverState(shell, snapshot, DebugDiscoverOperations(snapshot, shell.person.name), scope, push, pop, fixture::service)
|
||||
state = loaded
|
||||
if (resumeContent) loaded.focus(if (loaded.hasHero || !loaded.canRequest) 0 else 1)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(shell.route, state) { state.enterRoute(shell.route) }
|
||||
|
|
|
|||
|
|
@ -122,9 +122,18 @@ separator token and the shell's single hold guard. Both frames share the 180 ms
|
|||
|
||||
The TV keeps platform Roboto with the approved type sizes and weights. Browser
|
||||
system-font wrapping is not a per-title contract: no bundled font, forced title
|
||||
breaks or width compensation is applied. Native text-layout measurements accompany
|
||||
each acceptance capture; font-driven wrapping and text-block positions must be
|
||||
listed explicitly in its comparison report.
|
||||
breaks or width compensation is applied. Discover's own stacked text preserves
|
||||
untrimmed CSS line boxes without changing merged shell/library typography. Hero,
|
||||
sheet and shared focused-content shadows use tokenized drop shadows, not Material
|
||||
elevation; their alpha animates in the draw phase. Native text-layout measurements
|
||||
accompany each acceptance capture; remaining font-driven wrapping and text-block
|
||||
positions must be listed explicitly in its comparison report.
|
||||
|
||||
The asynchronous debug fixture seam hands loading focus back to the first loaded
|
||||
action when Discover is still the selected destination. A JVM regression releases
|
||||
the actual host's suspended fixture load and presses Android OK: it must open the
|
||||
hero rather than activate the nav. Fixture completion does not steal a different
|
||||
destination or nav selection.
|
||||
|
||||
Native Roborazzi captures use Robolectric SDK 34 and
|
||||
`w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). `make test` writes capture PNGs and
|
||||
|
|
|
|||
|
|
@ -267,6 +267,9 @@
|
|||
"frameBottomMin": {"$type": "dimension", "px": 80, "dp": 40, "source": "locked index.html:1042"},
|
||||
"anchorFitTop": {"$type": "dimension", "px": 700, "dp": 350, "source": "locked index.html:1062"},
|
||||
"anchorBottomClearance": {"$type": "dimension", "px": 40, "dp": 20, "source": "locked index.html:1062"},
|
||||
"reasonOpacity": {"$type": "number", "$value": 0.8, "source": "locked index.html:240"},
|
||||
"stageGap": {"$type": "dimension", "px": 10, "dp": 5, "source": "locked index.html:220"},
|
||||
"entryBorderOpacity": {"$type": "number", "$value": 0.08, "source": "locked index.html:149"},
|
||||
"ambientInset": {"$type": "dimension", "px": 80, "dp": 40, "source": "locked index.html:96"},
|
||||
"ambientBlur": {"$type": "dimension", "px": 60, "dp": 30, "source": "locked index.html:96"},
|
||||
"ambientScale": {"$type": "number", "$value": 1.1, "source": "locked index.html:96"},
|
||||
|
|
|
|||
|
|
@ -368,5 +368,19 @@
|
|||
"fallbackSaturationBottom": {"$type":"number","$value":0.26,"source":"index.html:130"},
|
||||
"fallbackLightnessBottom": {"$type":"number","$value":0.1,"source":"index.html:130"},
|
||||
"fallbackIconOpacity": {"$type":"number","$value":0.45,"source":"index.html:132"}
|
||||
},
|
||||
"lift": {
|
||||
"cardBlur": {"$type":"dimension","px":50,"dp":25,"source":"index.html:134; spec 4.5"},
|
||||
"cardY": {"$type":"dimension","px":18,"dp":9,"source":"index.html:134"},
|
||||
"cardOpacity": {"$type":"number","$value":0.65,"source":"index.html:134"},
|
||||
"buttonBlur": {"$type":"dimension","px":40,"dp":20,"source":"index.html:110; spec 4.5"},
|
||||
"buttonY": {"$type":"dimension","px":12,"dp":6,"source":"spec 4.5"},
|
||||
"buttonOpacity": {"$type":"number","$value":0.5,"source":"spec 4.5"},
|
||||
"heroBlur": {"$type":"dimension","px":80,"dp":40,"source":"index.html:97"},
|
||||
"heroY": {"$type":"dimension","px":30,"dp":15,"source":"index.html:97"},
|
||||
"heroOpacity": {"$type":"number","$value":0.6,"source":"index.html:97"},
|
||||
"sheetBlur": {"$type":"dimension","px":80,"dp":40,"source":"index.html:227"},
|
||||
"sheetX": {"$type":"dimension","px":-30,"dp":-15,"source":"index.html:227"},
|
||||
"sheetOpacity": {"$type":"number","$value":0.5,"source":"index.html:227"}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ fun CatalogueRecoveryCard(retry: Boolean, focused: Boolean, onClick: () -> Unit,
|
|||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||
Column(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
|
||||
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card)
|
||||
.background(C.surface, shape).border(G.borderWidth, C.border, shape).clickable(onClick = onClick).padding(G.tailPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon))
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.dropShadow
|
||||
import androidx.compose.ui.graphics.shadow.Shadow
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
|
@ -71,7 +74,11 @@ fun DiscoverBackdrop(title: DiscoverTitle, detail: Boolean = false, modifier: Mo
|
|||
DiscoverArtwork(title.poster, Modifier.offset(
|
||||
WolfskinTokens.Shell.stageWidth - D.heroSheetRight - if (detail) L.heroSheetWidth else D.heroSheetWidth,
|
||||
if (detail) L.navBand else D.heroSheetTop,
|
||||
).size(if (detail) L.heroSheetWidth else D.heroSheetWidth, if (detail) L.heroSheetHeight else D.heroSheetHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet)), poster = true)
|
||||
).size(if (detail) L.heroSheetWidth else D.heroSheetWidth, if (detail) L.heroSheetHeight else D.heroSheetHeight)
|
||||
.dropShadow(RoundedCornerShape(WolfskinTokens.Radius.heroSheet), Shadow(
|
||||
radius = WolfskinTokens.Lift.heroBlur, color = Color.Black.copy(alpha = WolfskinTokens.Lift.heroOpacity),
|
||||
offset = DpOffset(WolfskinTokens.Shell.zero, WolfskinTokens.Lift.heroY)))
|
||||
.clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet)), poster = true)
|
||||
} else DiscoverArtwork(title.backdrop ?: title.poster, Modifier.fillMaxSize())
|
||||
Box(Modifier.fillMaxSize().drawWithCache {
|
||||
val scrim = Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f), endX = size.width)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Arrangement
|
|||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
|
|
@ -22,9 +21,13 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.dropShadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.shadow.Shadow
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
|
||||
|
|
@ -36,20 +39,24 @@ fun DiscoverOverlay(state: DiscoverState) {
|
|||
val overlay = state.overlay
|
||||
if (overlay != null && !state.shell.pickerVisible) {
|
||||
Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable(onClick = {}).testTag("discover-overlay")) {
|
||||
Column(Modifier.align(Alignment.CenterEnd).width(L.sheetWidth).fillMaxSize().background(C.surface).padding(D.sheetPaddingX, D.sheetPaddingY), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) {
|
||||
Column(Modifier.align(Alignment.CenterEnd).width(L.sheetWidth).fillMaxSize()
|
||||
.dropShadow(RectangleShape, Shadow(radius = WolfskinTokens.Lift.sheetBlur,
|
||||
color = Color.Black.copy(alpha = WolfskinTokens.Lift.sheetOpacity),
|
||||
offset = DpOffset(WolfskinTokens.Lift.sheetX, WolfskinTokens.Shell.zero)))
|
||||
.background(C.surface).padding(D.sheetPaddingX, D.sheetPaddingY), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) {
|
||||
when (overlay) {
|
||||
is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state)
|
||||
is DiscoverOverlayState.Hidden -> {
|
||||
ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title) }
|
||||
DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok) }
|
||||
}
|
||||
}
|
||||
is DiscoverOverlayState.Restore -> {
|
||||
Text(overlay.title.title, color = C.fg, style = WolfskinTokens.Type.sheetTitle)
|
||||
Text("Hidden from ${state.shell.person.name}’s picks.", color = C.muted, style = WolfskinTokens.Type.body)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
Text(overlay.title.title, color = C.fg, style = DiscoverType.sheetTitle)
|
||||
Text("Hidden from ${state.shell.person.name}’s picks.", color = C.muted, style = DiscoverType.body)
|
||||
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title) }
|
||||
DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null }
|
||||
}
|
||||
|
|
@ -60,16 +67,16 @@ fun DiscoverOverlay(state: DiscoverState) {
|
|||
}
|
||||
state.toast?.let { text ->
|
||||
Box(Modifier.offset(WolfskinTokens.Shell.stageWidth - L.safeSide - WolfskinTokens.Library.captionWidth, L.navBand).width(WolfskinTokens.Library.captionWidth).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(L.buttonGap)) {
|
||||
Text(text, color = C.fg, style = WolfskinTokens.Type.body)
|
||||
Text(text, color = C.fg, style = DiscoverType.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
|
||||
Text(if (sheet.held) "Add for someone" else "Request", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
|
||||
Text(if (sheet.held) "Add for someone" else "Request", color = C.fg, style = DiscoverType.sheetTitle)
|
||||
Text("${sheet.title.title} · ${sheet.title.typeWord}${sheet.title.year?.let { " · $it" }.orEmpty()}",
|
||||
Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop), color = C.muted, style = WolfskinTokens.Type.body)
|
||||
Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop), color = C.muted, style = DiscoverType.body)
|
||||
if (state.isSignedIn) DiscoverRatings(sheet.title.ratings, Modifier.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
layout(placeable.width, placeable.height + (D.sheetRatingTop + D.sheetRatingBottom).roundToPx()) {
|
||||
|
|
@ -79,15 +86,17 @@ private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
|
|||
when (sheet.phase) {
|
||||
RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet)
|
||||
RequestPhase.LoadingRecipients -> {
|
||||
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = WolfskinTokens.Type.label)
|
||||
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label)
|
||||
repeat(3) {
|
||||
Row(Modifier.fillMaxWidth().height(L.sheetRow).background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)).padding(horizontal = D.sheetRowPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(D.sheetAvatar).background(C.surface2, CircleShape))
|
||||
Box(Modifier.width(D.skeletonNameWidth).height(D.skeletonNameHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Shell.skeletonRadius)))
|
||||
Box(Modifier.size(D.sheetAvatar).background(C.surface, CircleShape))
|
||||
Box(Modifier.width(D.skeletonNameWidth).height(D.skeletonNameHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Shell.skeletonRadius)))
|
||||
}
|
||||
}
|
||||
DiscoverButton("Close", true) { state.overlay = null }
|
||||
Box(Modifier.padding(top = D.sheetButtonTop)) {
|
||||
DiscoverButton("Close", true, primary = false) { state.overlay = null }
|
||||
}
|
||||
}
|
||||
else -> RequestOutcome(state, sheet)
|
||||
}
|
||||
|
|
@ -95,7 +104,7 @@ private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
|
|||
|
||||
@Composable
|
||||
private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) {
|
||||
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = WolfskinTokens.Type.label)
|
||||
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label)
|
||||
sheet.people.forEachIndexed { index, person ->
|
||||
val focused = sheet.focus == index
|
||||
val disabled = person.key in sheet.title.heldBy
|
||||
|
|
@ -105,18 +114,18 @@ private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) {
|
|||
.clickable(enabled = !disabled && sheet.phase == RequestPhase.Choose) { sheet.selected = index; sheet.focus = sheet.buttonIndex }
|
||||
.padding(horizontal = D.sheetRowPadding), horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Avatar(person, D.sheetAvatar, WolfskinTokens.Type.avatarLetterSheetRow)
|
||||
Text(person.name + if (person.key == sheet.viewer.key) " (you)" else "", Modifier.weight(1f), color = if (focused) C.onFocus else C.fg, style = WolfskinTokens.Type.listTitle)
|
||||
if (disabled) Text("In library", color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption)
|
||||
Text(person.name + if (person.key == sheet.viewer.key) " (you)" else "", Modifier.weight(1f), color = if (focused) C.onFocus else C.fg, style = DiscoverType.listTitle)
|
||||
if (disabled) Text("In library", color = (if (focused) C.onFocus else C.fg).copy(alpha = D.reasonOpacity), style = DiscoverType.reason)
|
||||
else Box(Modifier.size(D.sheetRadio).background(if (sheet.selected == index) C.accent else C.listRowRest.copy(alpha = 0f), CircleShape)
|
||||
.border(if (sheet.selected != index || focused) D.sheetRadioRing else WolfskinTokens.Shell.zero, if (focused) C.onFocus.copy(alpha = .5f) else C.fg.copy(alpha = .35f), CircleShape), contentAlignment = Alignment.Center) {
|
||||
if (sheet.selected == index) Box(Modifier.size(D.sheetRadioDot).background(C.onFocus, CircleShape))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sheet.blocked) Text("Season details are unknown right now, so this series can’t be requested yet. Nothing was sent.", color = C.warn, style = WolfskinTokens.Type.caption)
|
||||
else if (sheet.title.type == "tv" && !sheet.held) Text("Whole series: every listed season. Other seasons may download too.", color = C.dim, style = WolfskinTokens.Type.caption)
|
||||
if (sheet.blocked) Text("Season details are unknown right now, so this series can’t be requested yet. Nothing was sent.", color = C.warn, style = DiscoverType.caption)
|
||||
else if (sheet.title.type == "tv" && !sheet.held) Text("Whole series: every listed season. Other seasons may download too.", color = C.dim, style = DiscoverType.caption)
|
||||
Box(Modifier.padding(top = D.sheetButtonTop)) {
|
||||
DiscoverButton(if (sheet.blocked) "Close" else sheet.buttonLabel, sheet.focus == sheet.buttonIndex, glyph = if (sheet.blocked) null else "plus") { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) }
|
||||
DiscoverButton(if (sheet.blocked) "Close" else sheet.buttonLabel, sheet.focus == sheet.buttonIndex, glyph = if (sheet.blocked) null else "plus", primary = !sheet.blocked) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -141,8 +150,8 @@ private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) {
|
|||
}
|
||||
ReceiptPanel(heading, text, if (phase == RequestPhase.Receipt) C.fg else if (phase == RequestPhase.Refused) C.fail else C.warn,
|
||||
if (phase == RequestPhase.Receipt) sheet.result?.stage ?: "Waiting" else null)
|
||||
if (phase == RequestPhase.Receipt) Text("Follow it under Requests in Settings.", color = C.dim, style = WolfskinTokens.Type.caption)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
if (phase == RequestPhase.Receipt) Text("Follow it under Requests in Settings.", color = C.dim, style = DiscoverType.caption)
|
||||
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
if (phase in setOf(RequestPhase.NotConfirmed, RequestPhase.Pending, RequestPhase.RecipientsUnavailable)) {
|
||||
DiscoverButton(if (phase == RequestPhase.RecipientsUnavailable) "Try again" else "Check again", sheet.focus == sheet.buttonIndex) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) }
|
||||
DiscoverButton("Close", sheet.focus != sheet.buttonIndex, primary = false) { state.overlay = null }
|
||||
|
|
@ -155,12 +164,13 @@ private fun ReceiptPanel(title: String, text: String, color: androidx.compose.ui
|
|||
Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(WolfskinTokens.Shell.panelPaddingX, WolfskinTokens.Shell.panelPaddingY), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.panelGap)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph(if (title == "Requested" || title == "Hidden from your picks" || title.startsWith("Adding")) "check" else if (title == "Request unresolved") "clock" else "alert", color, Modifier.size(WolfskinTokens.Shell.panelIcon))
|
||||
Text(title, color = color, style = WolfskinTokens.Type.stateTitle.copy(fontWeight = FontWeight.Bold))
|
||||
Text(title, color = color, style = DiscoverType.stateTitle)
|
||||
}
|
||||
Text(text, color = C.muted, style = WolfskinTokens.Type.body)
|
||||
if (stage != null) Row(horizontalArrangement = Arrangement.spacedBy(L.navBlobAvatarPad), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph("clock", C.muted, Modifier.size(L.buttonIcon))
|
||||
Text(stage, color = C.muted, style = WolfskinTokens.Type.label)
|
||||
Text(text, color = C.muted, style = DiscoverType.body)
|
||||
if (stage != null) Row(horizontalArrangement = Arrangement.spacedBy(D.stageGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
val stageColor = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost" -> C.accent; else -> C.muted }
|
||||
Glyph("clock", stageColor, Modifier.size(L.buttonIcon))
|
||||
Text(stage, color = stageColor, style = DiscoverType.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ fun DiscoverRatings(ratings: List<DiscoverRating>, modifier: Modifier = Modifier
|
|||
if (ratings.isEmpty()) return
|
||||
val locale = LocalConfiguration.current.locales[0]
|
||||
val labels = remember(ratings, locale) { ratings.map { formatDiscoverRating(it, locale) } }
|
||||
val style = remember { WolfskinTokens.Type.ratingPlate.copy(fontFeatureSettings = "tnum") }
|
||||
val style = DiscoverType.ratings
|
||||
val markSize = with(LocalDensity.current) { style.fontSize.toDp() }
|
||||
val desaturated = remember { ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) }
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(markSize * .7142857f), verticalAlignment = Alignment.CenterVertically) {
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
|||
Row(Modifier.offset(L.safeSide, D.noteTop), horizontalArrangement = Arrangement.spacedBy(D.noteGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
val color = if (state.snapshot.status == DiscoverStatus.NoSeeds) C.muted else C.warn
|
||||
Glyph(if (state.snapshot.status == DiscoverStatus.NoSeeds) "eye" else "alert", color, Modifier.size(D.noteIcon))
|
||||
Text(if (state.snapshot.status == DiscoverStatus.NoSeeds) "Watch something to get picks for ${shell.person.name}." else "Picks unavailable right now.", color = color, style = WolfskinTokens.Type.body)
|
||||
Text(if (state.snapshot.status == DiscoverStatus.NoSeeds) "Watch something to get picks for ${shell.person.name}." else "Picks unavailable right now.", color = color, style = DiscoverType.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,19 +122,19 @@ private fun DiscoverHero(state: DiscoverState) {
|
|||
Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph("seerr", C.accentText, Modifier.size(L.buttonIcon))
|
||||
Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = WolfskinTokens.Type.label)
|
||||
Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = DiscoverType.label)
|
||||
}
|
||||
Spacer(Modifier.height(D.heroKickerGap))
|
||||
Text(title.title, Modifier.width(L.heroCopyWidth), color = C.fg, style = WolfskinTokens.Type.displayHero, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Text(title.title, Modifier.width(L.heroCopyWidth), color = C.fg, style = DiscoverType.hero, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Spacer(Modifier.height(D.heroTitleGap))
|
||||
Row(Modifier.height(D.heroFactsHeight), horizontalArrangement = Arrangement.spacedBy(D.heroFactsGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("${title.typeWord}${title.year?.let { " · $it" }.orEmpty()}", color = C.muted, style = WolfskinTokens.Type.body)
|
||||
if (title.genres.isNotEmpty()) Text(title.genres.take(2).joinToString(", "), color = C.muted, style = WolfskinTokens.Type.body)
|
||||
Text("${title.typeWord}${title.year?.let { " · $it" }.orEmpty()}", color = C.muted, style = DiscoverType.body)
|
||||
if (title.genres.isNotEmpty()) Text(title.genres.take(2).joinToString(", "), color = C.muted, style = DiscoverType.body)
|
||||
if (state.isSignedIn) DiscoverRatings(title.ratings)
|
||||
if (state.shell.person.key in title.heldBy) Text("In your library", color = C.accentText, style = WolfskinTokens.Type.body)
|
||||
if (state.shell.person.key in title.heldBy) Text("In your library", color = C.accentText, style = DiscoverType.body)
|
||||
}
|
||||
Spacer(Modifier.height(D.heroMetaGap))
|
||||
Text(title.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = WolfskinTokens.Type.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Text(title.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Spacer(Modifier.height(D.heroOverviewGap))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) }
|
||||
|
|
@ -156,7 +156,7 @@ private fun DiscoverChips(state: DiscoverState) {
|
|||
.background(if (focused) C.fg else if (state.filter == value) C.accentSoft else C.chipRest, RoundedCornerShape(WolfskinTokens.Radius.chip))
|
||||
.border(if (!focused && state.filter == value) WolfskinTokens.Focus.ringCardRing / 2 else WolfskinTokens.Shell.zero, if (!focused && state.filter == value) C.accent else C.chipRest.copy(alpha = 0f), RoundedCornerShape(WolfskinTokens.Radius.chip))
|
||||
.clickable { state.focus(1, index); state.key(ShellKey.Ok) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) {
|
||||
Text(label, color = if (focused) C.onFocus else if (state.filter == value) C.accentText else C.fg, style = WolfskinTokens.Type.label)
|
||||
Text(label, color = if (focused) C.onFocus else if (state.filter == value) C.accentText else C.fg, style = DiscoverType.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -166,17 +166,17 @@ private fun DiscoverChips(state: DiscoverState) {
|
|||
private fun DiscoverStatePanel(state: DiscoverState) {
|
||||
val signIn = state.snapshot.status == DiscoverStatus.NeedsSignIn
|
||||
Box(Modifier.fillMaxWidth().height(L.row_state)) {
|
||||
Column(Modifier.padding(start = L.safeSide, top = D.stateTop).width(L.stateWidth)
|
||||
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
.border(WolfskinTokens.Library.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
.padding(WolfskinTokens.Library.borderWidth).padding(D.statePaddingX, D.statePaddingY)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph(if (signIn) "profile" else "alert", if (signIn) C.accentText else C.warn, Modifier.size(L.buttonIcon))
|
||||
Text(if (signIn) "Discover needs sign-in" else "Discover is resting", color = C.fg, style = WolfskinTokens.Type.stateTitle)
|
||||
Column(Modifier.padding(start = L.safeSide, top = D.stateTop).width(L.stateWidth)
|
||||
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
.border(WolfskinTokens.Library.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
.padding(WolfskinTokens.Library.borderWidth).padding(D.statePaddingX, D.statePaddingY)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph(if (signIn) "profile" else "alert", if (signIn) C.accentText else C.warn, Modifier.size(L.buttonIcon))
|
||||
Text(if (signIn) "Discover needs sign-in" else "Discover is resting", color = C.fg, style = DiscoverType.stateTitle)
|
||||
}
|
||||
Text(if (signIn) "Sign this TV in to Conductarr to see picks and request titles. Your library plays as usual." else "Conductarr can’t be reached. Your library still plays.", Modifier.padding(top = L.navBlobAvatarPad, bottom = D.stateTextGap), color = C.muted, style = DiscoverType.body)
|
||||
DiscoverButton("Try again", state.contentFocused, primary = signIn) { state.refresh() }
|
||||
}
|
||||
Text(if (signIn) "Sign this TV in to Conductarr to see picks and request titles. Your library plays as usual." else "Conductarr can’t be reached. Your library still plays.", Modifier.padding(top = L.navBlobAvatarPad, bottom = D.stateTextGap), color = C.muted, style = WolfskinTokens.Type.body)
|
||||
DiscoverButton("Try again", state.contentFocused) { state.refresh() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -184,10 +184,10 @@ private fun DiscoverStatePanel(state: DiscoverState) {
|
|||
@Composable
|
||||
fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) {
|
||||
Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent)
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button)
|
||||
.background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.button))
|
||||
.clickable(onClick = onClick).padding(horizontal = L.buttonPaddingX), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon))
|
||||
Text(label, color = if (primary) C.onFocus else C.fg, style = WolfskinTokens.Type.button, maxLines = 1)
|
||||
Text(label, color = if (primary) C.onFocus else C.fg, style = DiscoverType.button, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,7 @@ import androidx.compose.foundation.border
|
|||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
|
|
@ -21,7 +19,6 @@ import androidx.compose.foundation.layout.width
|
|||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
|
@ -31,12 +28,9 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
|
||||
|
|
@ -69,18 +63,18 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
|
|||
initialized = true
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) {
|
||||
Text(lane.title, Modifier.offset(L.safeSide), color = C.fg, style = WolfskinTokens.Type.rowHeading, maxLines = 1)
|
||||
Text(lane.title, Modifier.offset(L.safeSide), color = C.fg, style = DiscoverType.rowHeading, maxLines = 1)
|
||||
LazyRow(state = cards, userScrollEnabled = false,
|
||||
contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap),
|
||||
horizontalArrangement = Arrangement.spacedBy(K.posterGap),
|
||||
modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) {
|
||||
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
|
||||
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
|
||||
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card)
|
||||
.background(Brush.linearGradient(listOf(Color.hsl(service.hue, .30f, .26f), Color.hsl(service.hue, .30f, .12f))), RoundedCornerShape(WolfskinTokens.Radius.card))
|
||||
.border(WolfskinTokens.Focus.ringCardRing / 2, C.border, RoundedCornerShape(WolfskinTokens.Radius.card))
|
||||
.border(WolfskinTokens.Library.borderWidth, C.border.copy(alpha = D.entryBorderOpacity), RoundedCornerShape(WolfskinTokens.Radius.card))
|
||||
.clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) {
|
||||
Text(service.name, Modifier.padding(start = D.entryPaddingX, bottom = D.entryPaddingY), style = D.entryText, color = C.fg)
|
||||
Text(service.name, Modifier.padding(start = D.entryPaddingX, bottom = D.entryPaddingY), style = DiscoverType.entry, color = C.fg)
|
||||
}
|
||||
} else {
|
||||
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.ui.text.PlatformTextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.LineHeightStyle
|
||||
|
||||
/** CSS line boxes survive native first/last-line trimming only within Discover's text stacks. */
|
||||
internal object DiscoverType {
|
||||
private val lineBox = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None)
|
||||
private val platform = PlatformTextStyle(includeFontPadding = false)
|
||||
val hero = WolfskinTokens.Type.displayHero.copy(lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val body = WolfskinTokens.Type.body.copy(lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val label = WolfskinTokens.Type.label.copy(lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val button = WolfskinTokens.Type.button.copy(lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val sheetTitle = WolfskinTokens.Type.sheetTitle.copy(lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val stateTitle = WolfskinTokens.Type.stateTitle.copy(fontWeight = FontWeight.Bold, lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val listTitle = WolfskinTokens.Type.listTitle.copy(lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val caption = WolfskinTokens.Type.caption.copy(lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val reason = WolfskinTokens.Type.caption.copy(fontWeight = FontWeight.Medium, lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val rowHeading = WolfskinTokens.Type.rowHeading.copy(lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val entry = WolfskinTokens.Discover.entryText.copy(lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val ratings = WolfskinTokens.Type.ratingPlate.copy(fontFeatureSettings = "tnum", lineHeightStyle = lineBox, platformStyle = platform)
|
||||
}
|
||||
|
|
@ -5,6 +5,10 @@ import androidx.compose.animation.core.animateFloatAsState
|
|||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.draw.dropShadow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
|
|
@ -25,16 +29,31 @@ fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier {
|
|||
return graphicsLayer { scaleX = animated; scaleY = animated }
|
||||
}
|
||||
|
||||
/** Content lifts are distinct from the white person/gear rings. */
|
||||
enum class FocusLift { None, Card, Button }
|
||||
|
||||
/** Draw the ring outside the slot; neither the gap nor the ring obscures content. */
|
||||
@Composable
|
||||
fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color): Modifier {
|
||||
val alpha by animateFloatAsState(if (focused) 1f else 0f,
|
||||
fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color, lift: FocusLift = FocusLift.None): Modifier {
|
||||
val ringAlpha by animateFloatAsState(if (focused) 1f else 0f,
|
||||
tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring")
|
||||
return drawBehind {
|
||||
if (alpha > 0f) {
|
||||
val shadow = if (lift == FocusLift.None) this else {
|
||||
val shape = remember(radius) { RoundedCornerShape(radius) }
|
||||
val density = LocalDensity.current
|
||||
val blur = with(density) { (if (lift == FocusLift.Card) WolfskinTokens.Lift.cardBlur else WolfskinTokens.Lift.buttonBlur).toPx() }
|
||||
val shift = with(density) { (if (lift == FocusLift.Card) WolfskinTokens.Lift.cardY else WolfskinTokens.Lift.buttonY).toPx() }
|
||||
dropShadow(shape) {
|
||||
this.radius = blur
|
||||
offset = Offset(0f, shift)
|
||||
this.color = Color.Black
|
||||
alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity
|
||||
}
|
||||
}
|
||||
return shadow.drawBehind {
|
||||
if (ringAlpha > 0f) {
|
||||
val outer = (gap + width).toPx()
|
||||
val inner = gap.toPx()
|
||||
drawRoundRect(color.copy(alpha = color.alpha * alpha), Offset(-outer, -outer), Size(size.width + 2 * outer, size.height + 2 * outer), CornerRadius(radius.toPx() + outer))
|
||||
drawRoundRect(color.copy(alpha = color.alpha * ringAlpha), Offset(-outer, -outer), Size(size.width + 2 * outer, size.height + 2 * outer), CornerRadius(radius.toPx() + outer))
|
||||
drawRoundRect(WolfskinTokens.Color.bg, Offset(-inner, -inner), Size(size.width + 2 * inner, size.height + 2 * inner), CornerRadius(radius.toPx() + inner))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ fun LibraryPoster(
|
|||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||
Box(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
|
||||
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card)
|
||||
.testTag("poster-${title.id}")) {
|
||||
Box(Modifier.fillMaxSize().clip(shape).background(C.surface2)) {
|
||||
val bitmap = remember(title.poster) { title.poster?.let { offlineArt?.invoke(it) } }
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import androidx.compose.foundation.border
|
|||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ fun LibrarySeeAllCard(count: Int, focused: Boolean, onClick: () -> Unit, modifie
|
|||
width: Dp = WolfskinTokens.Card.posterW, height: Dp = WolfskinTokens.Card.posterH) {
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||
Column(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent, FocusLift.Card)
|
||||
.background(WolfskinTokens.Color.surface, shape).border(WolfskinTokens.Library.borderWidth, WolfskinTokens.Color.border, shape)
|
||||
.clickable(onClick = onClick), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Library.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Glyph("interface", WolfskinTokens.Color.accent, Modifier.size(WolfskinTokens.Layout.badge))
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ private fun RecoveryPicker(state: ShellState) {
|
|||
fun PickerButton(label: String, focused: Boolean, primary: Boolean, onClick: () -> Unit) {
|
||||
val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
|
||||
Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent)
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button)
|
||||
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick)
|
||||
.padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) {
|
||||
Text(label, color = if (primary) C.onFocus else C.fg, style = WolfskinTokens.Type.button)
|
||||
|
|
|
|||
|
|
@ -337,6 +337,20 @@ object WolfskinTokens {
|
|||
const val fallbackLightnessBottom = 0.1f
|
||||
const val fallbackIconOpacity = 0.45f
|
||||
}
|
||||
object Lift {
|
||||
val cardBlur = 25.0.dp
|
||||
val cardY = 9.0.dp
|
||||
const val cardOpacity = 0.65f
|
||||
val buttonBlur = 20.0.dp
|
||||
val buttonY = 6.0.dp
|
||||
const val buttonOpacity = 0.5f
|
||||
val heroBlur = 40.0.dp
|
||||
val heroY = 15.0.dp
|
||||
const val heroOpacity = 0.6f
|
||||
val sheetBlur = 40.0.dp
|
||||
val sheetX = -15.0.dp
|
||||
const val sheetOpacity = 0.5f
|
||||
}
|
||||
object Discover {
|
||||
val sheetPaddingX = 32.0.dp
|
||||
val sheetPaddingY = 40.0.dp
|
||||
|
|
@ -389,6 +403,9 @@ object WolfskinTokens {
|
|||
val frameBottomMin = 40.0.dp
|
||||
val anchorFitTop = 350.0.dp
|
||||
val anchorBottomClearance = 20.0.dp
|
||||
const val reasonOpacity = 0.8f
|
||||
val stageGap = 5.0.dp
|
||||
const val entryBorderOpacity = 0.08f
|
||||
val ambientInset = 40.0.dp
|
||||
val ambientBlur = 30.0.dp
|
||||
const val ambientScale = 1.1f
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import android.view.WindowManager
|
|||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
|
|
|
|||
|
|
@ -209,6 +209,14 @@
|
|||
--library-headerSubtitle: 30.0px;
|
||||
--library-fallbackPadding: 22.0px;
|
||||
--library-fallbackIcon: 44.0px;
|
||||
--lift-cardBlur: 50.0px;
|
||||
--lift-cardY: 18.0px;
|
||||
--lift-buttonBlur: 40.0px;
|
||||
--lift-buttonY: 12.0px;
|
||||
--lift-heroBlur: 80.0px;
|
||||
--lift-heroY: 30.0px;
|
||||
--lift-sheetBlur: 80.0px;
|
||||
--lift-sheetX: -30.0px;
|
||||
--discover-sheetPaddingX: 64.0px;
|
||||
--discover-sheetPaddingY: 80.0px;
|
||||
--discover-sheetGap: 18.0px;
|
||||
|
|
@ -260,6 +268,7 @@
|
|||
--discover-frameBottomMin: 80.0px;
|
||||
--discover-anchorFitTop: 700.0px;
|
||||
--discover-anchorBottomClearance: 40.0px;
|
||||
--discover-stageGap: 10.0px;
|
||||
--discover-ambientInset: 80.0px;
|
||||
--discover-ambientBlur: 60.0px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
ffc070ec958ece59f2e8eecd1144f43cdaf7fdb53fe51b178f1b17457ea4151a discover-tokens.json
|
||||
1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json
|
||||
fcff646d59fb39e859c34c05037e05a7fb7583de2fe07315ec0176cf4d597980 generate_tokens.py
|
||||
050823bdd2bb71181a7b7c849e412129f312a150a27c2e5b9b3fbc1f5ccece0f shell-tokens.json
|
||||
ab20caec74fcbedadfbc59449b227a11a62cf79e8bc862d5f95ec95d1b8574db src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
4b741830cda8b905d71f97bf88ee8d6cca3e1cf0f881bc06c5525cbf05378808 tokens.css
|
||||
b1553e7dd694e5bafb770f6a583c969fe738fa20085b3e7e46e6555f46d0cbdc shell-tokens.json
|
||||
0818af386cc057730c08328f828e9859afa4ad8a40eb665ca1f6dff90146a670 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
d8c55bef3d7a911466d90902ad144d8283f5f0a73367ade1a0fcc3bd640722e0 tokens.css
|
||||
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue