Match Home focus and recovery details
This commit is contained in:
parent
c534ea75c2
commit
3b7249888f
14 changed files with 115 additions and 18 deletions
|
|
@ -153,6 +153,11 @@ the next shelf. Rating plates use the TV locale, start at 14 sp, shrink to 12 sp
|
|||
and drop trailing entries rather than wrap. Their uncached read gate is 350 ms;
|
||||
cached focus returns without that delay.
|
||||
|
||||
Focus rings and native lift draw outside fixed slots. The Saved chip uses its
|
||||
own measured geometry rather than the smaller Settings pill; caption separators
|
||||
use the approved 40% opacity token. Vertical moves clamp to the short target row,
|
||||
including a final See all card, rather than preserving an invalid column.
|
||||
|
||||
`make test` includes `HomeCaptureTest` (23 native acceptance states) and the
|
||||
data-driven paths in `wolfskin-home-presses.json`: play next 2, resume 3, Discover
|
||||
4, deep-row Back/Down 9, Trending 13 and Trending's See all card 33 presses.
|
||||
|
|
|
|||
|
|
@ -317,6 +317,19 @@
|
|||
"px": 16,
|
||||
"dp": 8.0,
|
||||
"source": "locked index.html:191"
|
||||
}
|
||||
},
|
||||
"hairline": { "$type": "dimension", "px": 2, "dp": 1.0, "source": "locked index.html:87,146,194" },
|
||||
"savedHeight": { "$type": "dimension", "px": 52, "dp": 26.0, "source": "locked index.html:87" },
|
||||
"savedPadding": { "$type": "dimension", "px": 20, "dp": 10.0, "source": "locked index.html:87" },
|
||||
"savedGap": { "$type": "dimension", "px": 10, "dp": 5.0, "source": "locked index.html:87" },
|
||||
"savedIcon": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:88" },
|
||||
"savedRadius": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:87" },
|
||||
"cardLiftOffset": { "$type": "dimension", "px": 18, "dp": 9.0, "source": "locked index.html:134" },
|
||||
"cardLiftBlur": { "$type": "dimension", "px": 50, "dp": 25.0, "source": "locked index.html:134" },
|
||||
"cardLiftOpacity": { "$type": "number", "$value": 0.65, "source": "locked index.html:134" },
|
||||
"buttonLiftOffset": { "$type": "dimension", "px": 12, "dp": 6.0, "source": "locked index.html:110" },
|
||||
"buttonLiftBlur": { "$type": "dimension", "px": 40, "dp": 20.0, "source": "locked index.html:110" },
|
||||
"buttonLiftOpacity": { "$type": "number", "$value": 0.5, "source": "locked index.html:110" },
|
||||
"factSeparatorOpacity": { "$type": "number", "$value": 0.4, "source": "approved spec 4.4" }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier =
|
|||
Row(Modifier.clipToBounds(), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Layout.captionOffset)) {
|
||||
val entries = facts.split(" · ") + listOfNotNull(availability)
|
||||
entries.forEachIndexed { index, fact ->
|
||||
if (index > 0) Text("·", color = WolfskinTokens.Color.fg.copy(alpha = .4f), style = WolfskinTokens.Type.caption)
|
||||
if (index > 0) Text("·", color = WolfskinTokens.Color.fg.copy(alpha = WolfskinTokens.Home.factSeparatorOpacity), style = WolfskinTokens.Type.caption)
|
||||
Text(fact, color = if (availability != null && index == entries.lastIndex) WolfskinTokens.Color.accentText else WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1, softWrap = false)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,11 +13,15 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
|||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
/** Only transforms animate: focus never changes measured geometry. */
|
||||
|
|
@ -46,6 +50,26 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color:
|
|||
}
|
||||
}
|
||||
|
||||
/** Native lift draws outside the slot without allocating an offscreen layer per card. */
|
||||
@Composable
|
||||
fun Modifier.focusLift(focused: Boolean, radius: Dp, offset: Dp, blur: Dp, opacity: Float): Modifier {
|
||||
val alpha by animateFloatAsState(if (focused) 1f else 0f,
|
||||
tween(WolfskinTokens.Motion.fast), label = "focus-lift")
|
||||
return drawWithCache {
|
||||
val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = WolfskinTokens.Color.bg.toArgb()
|
||||
setShadowLayer(blur.toPx(), 0f, offset.toPx(), WolfskinTokens.Color.sheetScrim.copy(alpha = opacity).toArgb())
|
||||
}
|
||||
val corner = radius.toPx()
|
||||
onDrawBehind {
|
||||
if (alpha > 0f) {
|
||||
paint.alpha = (alpha * 255).toInt()
|
||||
drawIntoCanvas { it.nativeCanvas.drawRoundRect(0f, 0f, size.width, size.height, corner, corner, paint) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Pointing changes the same logical focus as the D-pad without activating an action. */
|
||||
@Composable
|
||||
fun Modifier.focusOnPointerEnter(onEnter: () -> Unit): Modifier {
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
|
|||
Spacer(Modifier.height(H.overviewBottom))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
val primary = if (row.heroKind == "pick") "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
|
||||
PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }) { onButton(0) }
|
||||
if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }) { onButton(1) }
|
||||
PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }, lift = true) { onButton(0) }
|
||||
if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, lift = true) { onButton(1) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -78,7 +78,7 @@ fun HomeSection(person: ShellPerson, savedAt: String?) {
|
|||
@Composable
|
||||
fun HomeRecovery(row: HomeRow, focused: Boolean, retry: () -> Unit, onHover: () -> Unit) {
|
||||
Box(Modifier.fillMaxWidth().height(L.row_state)) {
|
||||
Column(Modifier.offset(L.safeSide, H.stateTop).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).border(WolfskinTokens.Focus.ringCardRing / 2, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = H.statePaddingX, vertical = H.statePaddingY)) {
|
||||
Column(Modifier.offset(L.safeSide, H.stateTop).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).border(H.hairline, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = H.statePaddingX, vertical = H.statePaddingY)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.plateTop)) {
|
||||
Glyph(if (row.calm) "profile" else "alert", if (row.calm) C.accentText else C.warn, Modifier.size(H.stateIcon))
|
||||
Text(row.title, color = C.fg, style = T.stateTitle.copy(fontWeight = FontWeight.Bold))
|
||||
|
|
@ -86,7 +86,7 @@ fun HomeRecovery(row: HomeRow, focused: Boolean, retry: () -> Unit, onHover: ()
|
|||
Spacer(Modifier.height(H.stateGap))
|
||||
Text(row.text, color = C.muted, style = T.body)
|
||||
Spacer(Modifier.height(H.stateTextBottom))
|
||||
PickerButton("Try again", focused, row.calm, onFocus = onHover, onClick = retry)
|
||||
PickerButton("Try again", focused, row.calm, onFocus = onHover, lift = true, onClick = retry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ fun HomeScreen(
|
|||
Text(item.text, color = if (item.calm) C.muted else C.warn, style = WolfskinTokens.Type.body)
|
||||
}
|
||||
HomeRowKind.Explore -> Box(Modifier.fillMaxWidth().height(L.row_endcta).padding(start = L.safeSide, top = H.endTop)) {
|
||||
PickerButton("Explore more in Discover", selected, true, "seerr", onFocus = { focus.focus(index, 0) }) { activate(0) }
|
||||
PickerButton("Explore more in Discover", selected, true, "seerr", onFocus = { focus.focus(index, 0) }, lift = true) { activate(0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ fun HomeShelf(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRat
|
|||
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + L.rowGapAfter * 2), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) {
|
||||
itemsIndexed(cards, key = { index, title -> title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title ->
|
||||
val selected = focused && column == index
|
||||
Box(Modifier.size(width, height).zIndex(if (selected) 2f else 0f).focusScale(selected, WolfskinTokens.Focus.scaleCard).focusRing(selected, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).testTag("home-card:${row.id}:$index").then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) }.clickable { onCard(index) } else Modifier)) {
|
||||
Box(Modifier.size(width, height).zIndex(if (selected) 2f else 0f).focusScale(selected, WolfskinTokens.Focus.scaleCard).focusLift(selected, WolfskinTokens.Radius.card, H.cardLiftOffset, H.cardLiftBlur, H.cardLiftOpacity).focusRing(selected, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).testTag("home-card:${row.id}:$index").then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) }.clickable { onCard(index) } else Modifier)) {
|
||||
Box(Modifier.fillMaxSize().clip(RoundedCornerShape(WolfskinTokens.Radius.card))) {
|
||||
when {
|
||||
row.skeleton -> Box(Modifier.fillMaxSize().background(C.surface))
|
||||
|
|
@ -95,7 +95,7 @@ private fun TileOverlay(title: HomeTitle) {
|
|||
|
||||
@Composable
|
||||
private fun SeeAll(total: Int) {
|
||||
Column(Modifier.fillMaxSize().background(C.surface).border(WolfskinTokens.Focus.ringCardRing, C.border, RoundedCornerShape(WolfskinTokens.Radius.card)), verticalArrangement = Arrangement.spacedBy(H.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Column(Modifier.fillMaxSize().background(C.surface).border(H.hairline, C.border, RoundedCornerShape(WolfskinTokens.Radius.card)), verticalArrangement = Arrangement.spacedBy(H.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Glyph("interface", C.accent, Modifier.size(H.seeAllIcon))
|
||||
Text("See all", color = C.fg, style = T.listTitle)
|
||||
Text("$total", color = C.muted, style = T.label)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import androidx.compose.ui.platform.LocalDensity
|
|||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
||||
|
||||
|
|
@ -100,10 +101,10 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
|
|||
}
|
||||
}
|
||||
state.view.savedAt?.let { time ->
|
||||
Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide - L.navAvatar - L.buttonGap, S.navItemTop + (L.navItemHeight - S.settingsPillHeight) / 2)
|
||||
.height(S.settingsPillHeight).border(WolfskinTokens.Focus.ringCardRing / 2, C.warn.copy(alpha = .45f), RoundedCornerShape(WolfskinTokens.Radius.pill))
|
||||
.padding(horizontal = S.settingsPillPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsPillGap)) {
|
||||
Glyph("wifi", C.warn, Modifier.size(S.settingsPillHeight - S.settingsPillGap))
|
||||
Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide - L.navAvatar - L.navItemGap, S.navItemTop + (L.navItemHeight - H.savedHeight) / 2)
|
||||
.height(H.savedHeight).border(H.hairline, C.warn.copy(alpha = .5f), RoundedCornerShape(H.savedRadius))
|
||||
.padding(horizontal = H.savedPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.savedGap)) {
|
||||
Glyph("wifi", C.warn, Modifier.size(H.savedIcon))
|
||||
Text("Saved $time", color = C.warn, style = WolfskinTokens.Type.caption)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,9 +102,10 @@ private fun RecoveryPicker(state: ShellState) {
|
|||
|
||||
/** Recovery actions remain focusable while profiles cannot be read. */
|
||||
@Composable
|
||||
fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
|
||||
fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null, lift: Boolean = false, 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)
|
||||
.then(if (lift) Modifier.focusLift(focused, WolfskinTokens.Radius.button, WolfskinTokens.Home.buttonLiftOffset, WolfskinTokens.Home.buttonLiftBlur, WolfskinTokens.Home.buttonLiftOpacity) else Modifier)
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent)
|
||||
.then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier)
|
||||
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick)
|
||||
|
|
|
|||
|
|
@ -334,5 +334,18 @@ object WolfskinTokens {
|
|||
val heroWashInset = 40.0.dp
|
||||
val noteTop = 7.0.dp
|
||||
val noteGap = 8.0.dp
|
||||
val hairline = 1.0.dp
|
||||
val savedHeight = 26.0.dp
|
||||
val savedPadding = 10.0.dp
|
||||
val savedGap = 5.0.dp
|
||||
val savedIcon = 14.0.dp
|
||||
val savedRadius = 14.0.dp
|
||||
val cardLiftOffset = 9.0.dp
|
||||
val cardLiftBlur = 25.0.dp
|
||||
const val cardLiftOpacity = 0.65f
|
||||
val buttonLiftOffset = 6.0.dp
|
||||
val buttonLiftBlur = 20.0.dp
|
||||
const val buttonLiftOpacity = 0.5f
|
||||
const val factSeparatorOpacity = 0.4f
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineJoin="round" />
|
||||
<path android:pathData="M4,4 H10 V10 H4 Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineJoin="round" />
|
||||
<path android:pathData="M14,4 H20 V10 H14 Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineJoin="round" />
|
||||
<path android:pathData="M4,14 H10 V20 H4 Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineJoin="round" />
|
||||
<path android:pathData="M14,14 H20 V20 H14 Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
|
|
|
|||
|
|
@ -105,6 +105,33 @@ class HomeKeysTest {
|
|||
assertNull(action)
|
||||
}
|
||||
|
||||
@Test fun downClampsToShortRowsAndNeverMovesPastTheFinalSeeAll() {
|
||||
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false }
|
||||
val cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards
|
||||
val long = HomeRow(id = "long", kind = HomeRowKind.Shelf, cards = cards)
|
||||
val short = long.copy(id = "short", cards = cards.take(2), total = 2)
|
||||
val tail = short.copy(id = "tail", total = 20)
|
||||
shell.view.cards[long.id] = cards.lastIndex
|
||||
var action: HomeAction? = null
|
||||
rule.setContent { WolfskinTheme {
|
||||
ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(long, short, tail)), modifier) { action = it } }
|
||||
} }
|
||||
press("DOWN")
|
||||
assertEquals(short.id, listOf(long, short, tail)[shell.view.row].id)
|
||||
assertEquals(1, shell.view.cards[short.id])
|
||||
rule.onNodeWithTag("home-card:short:1").assertIsDisplayed()
|
||||
press("RIGHT")
|
||||
assertEquals(1, shell.view.cards[short.id])
|
||||
press("DOWN")
|
||||
press("RIGHT")
|
||||
press("DOWN")
|
||||
assertEquals(2, shell.view.row)
|
||||
assertEquals(2, shell.view.cards[tail.id])
|
||||
rule.onNodeWithTag("home-card:tail:2").assertIsDisplayed()
|
||||
press("OK")
|
||||
assertEquals(tail.id, (action as HomeAction.SeeAll).row.id)
|
||||
}
|
||||
|
||||
private fun press(name: String) {
|
||||
val key = when (name) {
|
||||
"UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown
|
||||
|
|
|
|||
|
|
@ -226,4 +226,14 @@
|
|||
--home-heroWashInset: 80.0px;
|
||||
--home-noteTop: 14.0px;
|
||||
--home-noteGap: 16.0px;
|
||||
--home-hairline: 2.0px;
|
||||
--home-savedHeight: 52.0px;
|
||||
--home-savedPadding: 20.0px;
|
||||
--home-savedGap: 10.0px;
|
||||
--home-savedIcon: 28.0px;
|
||||
--home-savedRadius: 28.0px;
|
||||
--home-cardLiftOffset: 18.0px;
|
||||
--home-cardLiftBlur: 50.0px;
|
||||
--home-buttonLiftOffset: 12.0px;
|
||||
--home-buttonLiftBlur: 40.0px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
28f6f7bb878b8388ce04293cd2819bc1cb684da34925c9ddcded78fcddf6d1dd generate_tokens.py
|
||||
77a489b428b038a874624c9d7d881e69b9f20ca478961743061b76ce31b7bd31 home-tokens.json
|
||||
73398fc0d3d6b780170fa6d7ef9246f788c800c499981fde092fe790330c8ad1 home-tokens.json
|
||||
6d1d19656b40e6a442cb548bb238bb12244adb6b3b717ed9e564af61119559c8 shell-tokens.json
|
||||
b051a021d1fc9bb7d03287891f4a7eb62e4eaee4058d54e13bbff108924c6fa0 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
853f22d6f2572f55a2c8a0cac7e5ba11647d3d70e1b2c0be70d23cf06c7f2e58 tokens.css
|
||||
6e0fc478d40589aa552b880a91940361e9f6c85ef720103ffa8eb87358e296ac src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
7c8bc2d2db273e67a0b45cacdbbe9046bb4f4224b05d83f8e103dedb95138888 tokens.css
|
||||
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue