diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt new file mode 100644 index 00000000..b18f6a22 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt @@ -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() + + @Test fun firstOkAfterLoadingOpensTheHeroInsteadOfSelectingTheNav() { + val pending = CompletableDeferred() + 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() + 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) + } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt index f0bcc90e..b107bd97 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt @@ -29,15 +29,23 @@ internal val LocalDiscoverState = compositionLocalOf { 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) } diff --git a/docker/README.md b/docker/README.md index fb450b83..7f846780 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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 diff --git a/wolfskin-design/discover-tokens.json b/wolfskin-design/discover-tokens.json index 9eeceebf..83266ff5 100644 --- a/wolfskin-design/discover-tokens.json +++ b/wolfskin-design/discover-tokens.json @@ -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"}, diff --git a/wolfskin-design/shell-tokens.json b/wolfskin-design/shell-tokens.json index 4583ffe7..2e33e17e 100644 --- a/wolfskin-design/shell-tokens.json +++ b/wolfskin-design/shell-tokens.json @@ -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"} } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt index 5568e985..db0a22f6 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt @@ -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)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt index e3aba076..670e473b 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt @@ -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) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt index c2b5d2e9..2a01136c 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt @@ -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) } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverRatings.kt index 19f2687c..62a55b57 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverRatings.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverRatings.kt @@ -29,7 +29,7 @@ fun DiscoverRatings(ratings: List, 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) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt index 8873dcde..629fdc0e 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt @@ -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) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt index e0439d01..8883d432 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt @@ -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 -> diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverType.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverType.kt new file mode 100644 index 00000000..05a3a675 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverType.kt @@ -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) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt index 14a1cc76..ddb37ea7 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt @@ -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)) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt index 4cd7b704..7dfbca5e 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt @@ -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) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt index 99a60ada..f9ada462 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt @@ -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 diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibrarySeeAllCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibrarySeeAllCard.kt index 73bba003..cba21b48 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibrarySeeAllCard.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibrarySeeAllCard.kt @@ -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)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt index 1bef6689..df3eb325 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt @@ -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) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index d967b876..56128f96 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -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 diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt index c028387c..50e8401c 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt @@ -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 diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index 844e6d93..39df8a87 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -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; } diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index 0a4f6be4..ffcb5461 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -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