Round 2 release candidate 3: polish lanes A-E, season reel and trailer return #82
17 changed files with 903 additions and 29 deletions
|
|
@ -30,6 +30,7 @@
|
|||
"receiptPaddingX": { "$type": "dimension", "px": 30, "dp": 15, "source": "index.html:243" },
|
||||
"receiptGap": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:243" },
|
||||
"receiptGlyph": { "$type": "dimension", "px": 36, "dp": 18, "source": "index.html:245" },
|
||||
"thumbGlyph": { "$type": "dimension", "px": 40, "dp": 20, "source": "ui-polish P12, approved 2026-11-07: film/TV glyph centred in a request thumbnail without a poster" },
|
||||
"buttonMargin": { "$type": "dimension", "px": 12, "dp": 6, "source": "index.html:241" },
|
||||
"skeletonTitleWidth": { "$type": "dimension", "px": 360, "dp": 180, "source": "index.html:1902" },
|
||||
"skeletonTitleHeight": { "$type": "dimension", "px": 34, "dp": 17, "source": "index.html:1902" },
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
|
|
@ -18,6 +20,9 @@ import androidx.compose.ui.draw.drawWithCache
|
|||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
|
@ -37,7 +42,7 @@ fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier {
|
|||
/** Lift shadows remain specific to cards and buttons. */
|
||||
enum class FocusLift { None, Card, Button }
|
||||
|
||||
/** Every focus ring straddles the surface edge above content, without a background gap. */
|
||||
/** Hosted cards keep their lift and share the surface stroke; standalone rings straddle the surface edge. */
|
||||
@Composable
|
||||
fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier {
|
||||
val reduced = reducedMotion()
|
||||
|
|
@ -55,6 +60,23 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift
|
|||
alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity
|
||||
}
|
||||
}
|
||||
val host = LocalCardRing.current.takeIf { lift == FocusLift.Card }
|
||||
if (host != null) {
|
||||
val owner = remember { Any() }
|
||||
val density = LocalDensity.current
|
||||
val scale = WolfskinTokens.Focus.scaleCard
|
||||
val corner = with(density) { radius.toPx() } * scale
|
||||
val stroke = with(density) { width.toPx() } * scale
|
||||
DisposableEffect(host, focused) {
|
||||
onDispose { if (focused) host.release(owner) }
|
||||
}
|
||||
SideEffect { if (focused) host.retainFocus() }
|
||||
return if (!focused) shadow else shadow.onGloballyPositioned { coordinates ->
|
||||
val center = coordinates.localToRoot(Offset(coordinates.size.width / 2f, coordinates.size.height / 2f))
|
||||
val size = Size(coordinates.size.width * scale, coordinates.size.height * scale)
|
||||
host.update(owner, CardRingGeometry(Rect(center - Offset(size.width / 2, size.height / 2), size), corner, stroke))
|
||||
}
|
||||
}
|
||||
return shadow.drawWithCache {
|
||||
val stroke = Stroke(width.toPx())
|
||||
val corner = CornerRadius(radius.toPx())
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationVector4D
|
||||
import androidx.compose.animation.core.TwoWayConverter
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.layout.onPlaced
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
||||
|
||||
private val boundsConverter = TwoWayConverter<Rect, AnimationVector4D>(
|
||||
{ AnimationVector4D(it.left, it.top, it.right, it.bottom) },
|
||||
{ Rect(it.v1, it.v2, it.v3, it.v4) },
|
||||
)
|
||||
|
||||
internal data class CardRingGeometry(val bounds: Rect, val radius: Float, val stroke: Float)
|
||||
private data class CardRingTarget(val owner: Any, val geometry: CardRingGeometry)
|
||||
internal val LocalCardRing = staticCompositionLocalOf<CardRingMotion?> { null }
|
||||
|
||||
/** Target geometry follows layout; only the displacement from it carries spring velocity. */
|
||||
@Stable
|
||||
internal class CardRingMotion(private val scope: CoroutineScope) {
|
||||
var reduced = false
|
||||
private var target by mutableStateOf<CardRingTarget?>(null)
|
||||
private var retained by mutableStateOf<CardRingGeometry?>(null)
|
||||
private val displacement = Animatable(Rect.Zero, boundsConverter)
|
||||
private val radiusDisplacement = Animatable(0f)
|
||||
val opacity = Animatable(0f)
|
||||
private var geometryJob: Job? = null
|
||||
private var fadeJob: Job? = null
|
||||
private var releaseJob: Job? = null
|
||||
private var stroke = Stroke(0f)
|
||||
|
||||
fun geometry(): CardRingGeometry? {
|
||||
val destination = target?.geometry ?: return retained
|
||||
if (reduced) return destination
|
||||
return CardRingGeometry(destination.bounds + displacement.value,
|
||||
(destination.radius + radiusDisplacement.value).coerceAtLeast(0f), destination.stroke)
|
||||
}
|
||||
|
||||
fun update(owner: Any, geometry: CardRingGeometry) {
|
||||
releaseJob?.cancel()
|
||||
if (stroke.width != geometry.stroke) stroke = Stroke(geometry.stroke)
|
||||
val previous = target
|
||||
if (previous?.owner === owner) {
|
||||
target = CardRingTarget(owner, geometry)
|
||||
return
|
||||
}
|
||||
val current = this.geometry()
|
||||
val velocity = displacement.velocity
|
||||
val radiusVelocity = radiusDisplacement.velocity
|
||||
geometryJob?.cancel()
|
||||
target = CardRingTarget(owner, geometry)
|
||||
geometryJob = scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
displacement.snapTo(if (previous != null && current != null && !reduced) current.bounds - geometry.bounds else Rect.Zero)
|
||||
radiusDisplacement.snapTo(if (previous != null && current != null && !reduced) current.radius - geometry.radius else 0f)
|
||||
if (!reduced) coroutineScope {
|
||||
launch { displacement.animateTo(Rect.Zero, spring(M.navBlobDampingRatio, M.navBlobStiffness), initialVelocity = velocity) }
|
||||
launch { radiusDisplacement.animateTo(0f, spring(M.navBlobDampingRatio, M.navBlobStiffness), initialVelocity = radiusVelocity) }
|
||||
}
|
||||
}
|
||||
if (previous == null) fade(true)
|
||||
}
|
||||
|
||||
fun drawRing(scope: DrawScope, origin: Offset) {
|
||||
val geometry = target?.geometry ?: retained ?: return
|
||||
val alpha = opacity.value
|
||||
if (alpha <= 0f) return
|
||||
val delta = if (target != null && !reduced) displacement.value else Rect.Zero
|
||||
val radius = geometry.radius + if (target != null && !reduced) radiusDisplacement.value else 0f
|
||||
scope.drawRoundRect(WolfskinTokens.Color.accent.copy(alpha = alpha),
|
||||
topLeft = geometry.bounds.topLeft + Offset(delta.left, delta.top) - origin,
|
||||
size = Size(geometry.bounds.width + delta.right - delta.left, geometry.bounds.height + delta.bottom - delta.top),
|
||||
cornerRadius = CornerRadius(radius.coerceAtLeast(0f)), style = stroke)
|
||||
}
|
||||
|
||||
fun retainFocus() { releaseJob?.cancel() }
|
||||
|
||||
fun release(owner: Any) {
|
||||
// A replacement card registers during layout in the same composition; it must not fade the ring.
|
||||
releaseJob?.cancel()
|
||||
releaseJob = scope.launch {
|
||||
yield()
|
||||
if (target?.owner === owner) {
|
||||
retained = geometry()
|
||||
target = null
|
||||
geometryJob?.cancel()
|
||||
fade(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fade(visible: Boolean) {
|
||||
fadeJob?.cancel()
|
||||
fadeJob = scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
opacity.animateTo(if (visible) 1f else 0f, tween(motionDuration(M.focusMove, reduced), easing = M.easeOut))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private operator fun Rect.plus(other: Rect) = Rect(left + other.left, top + other.top, right + other.right, bottom + other.bottom)
|
||||
private operator fun Rect.minus(other: Rect) = Rect(left - other.left, top - other.top, right - other.right, bottom - other.bottom)
|
||||
|
||||
/** One draw-only card ring sits above surface content without changing layout or focus ownership. */
|
||||
@Composable
|
||||
fun FocusRingHost(content: @Composable () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val motion = remember(scope) { CardRingMotion(scope) }
|
||||
val reduced = reducedMotion()
|
||||
SideEffect { motion.reduced = reduced }
|
||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||
Box(Modifier.onPlaced { origin = it.positionInRoot() }.drawWithContent {
|
||||
drawContent()
|
||||
motion.drawRing(this, origin)
|
||||
}) {
|
||||
CompositionLocalProvider(LocalCardRing provides motion, content = content)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +1,72 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.AnimationState
|
||||
import androidx.compose.animation.core.animateTo
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.WeakHashMap
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sign
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/** Every frame follows focus with the same bounded scroll tween. */
|
||||
internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) {
|
||||
if (distance == 0f) return
|
||||
list.scroll {
|
||||
var previous = 0f
|
||||
animate(0f, distance, animationSpec = tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll,
|
||||
easing = WolfskinTokens.Motion.easeOut)) { value, _ ->
|
||||
scrollBy(value - previous)
|
||||
previous = value
|
||||
private class ScrollMomentum {
|
||||
var velocity = 0f
|
||||
var frameTime = Long.MIN_VALUE
|
||||
var generation = 0L
|
||||
fun reset() { velocity = 0f; frameTime = Long.MIN_VALUE }
|
||||
|
||||
fun release(context: CoroutineContext, generation: Long) {
|
||||
if (this.generation != generation) return
|
||||
if (context.isActive) { reset(); return }
|
||||
// A replacement must claim the carry before the next frame, without keeping the old mutation alive.
|
||||
CoroutineScope(context + NonCancellable).launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
try { withFrameNanos { } }
|
||||
finally { if (this@ScrollMomentum.generation == generation) reset() }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scroll mutations and animation callbacks run on the UI thread; values never retain their weak keys.
|
||||
private val scrollMomentum = WeakHashMap<LazyListState, ScrollMomentum>()
|
||||
private val scrollAngularFrequency = sqrt(WolfskinTokens.Motion.navBlobStiffness)
|
||||
|
||||
/** Key-driven frames and shelves share a bounded nav spring and carry same-direction velocity. */
|
||||
internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) {
|
||||
val momentum = scrollMomentum.getOrPut(list) { ScrollMomentum() }
|
||||
val generation = ++momentum.generation
|
||||
if (distance == 0f) { momentum.reset(); return }
|
||||
try {
|
||||
list.scroll {
|
||||
if (NetworkArtTransition.reduced) {
|
||||
momentum.reset()
|
||||
scrollBy(distance)
|
||||
} else {
|
||||
val maximum = scrollAngularFrequency * abs(distance)
|
||||
val velocity = if (momentum.velocity.sign == distance.sign) momentum.velocity.coerceIn(-maximum, maximum) else 0f
|
||||
var previous = 0f
|
||||
// A fresh frame-clock start would insert a stationary frame on every held-key repeat.
|
||||
AnimationState(0f, velocity, lastFrameTimeNanos = momentum.frameTime).animateTo(distance,
|
||||
animationSpec = spring(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness),
|
||||
sequentialAnimation = momentum.frameTime != Long.MIN_VALUE) {
|
||||
scrollBy(value - previous)
|
||||
previous = value
|
||||
momentum.velocity = this.velocity
|
||||
momentum.frameTime = lastFrameTimeNanos
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
val context = currentCoroutineContext()
|
||||
momentum.release(context, generation)
|
||||
context.ensureActive()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
|
|
@ -69,7 +67,7 @@ fun HomeScreen(
|
|||
(heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx()
|
||||
}.coerceAtLeast(0f)
|
||||
val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset
|
||||
view.listState.animateScrollBy(target - current, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut))
|
||||
animateFrameScroll(view.listState, target - current)
|
||||
view.home.positionedKey = key
|
||||
view.home.positionedReset = view.home.resetScroll
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
||||
|
||||
/** One pending playback start per surface; the player opens only after the page has faded to black. */
|
||||
@Stable
|
||||
class PlaybackStart {
|
||||
/** The control that reads "Starting…" while the stream is prepared, or null. */
|
||||
var starting by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
/** Set once the player is ready; the veil runs it exactly once, when the screen is black. */
|
||||
var launch by mutableStateOf<(() -> Unit)?>(null)
|
||||
private set
|
||||
|
||||
fun begin(control: String) { starting = control; launch = null }
|
||||
fun fail() { starting = null; launch = null }
|
||||
fun ready(open: () -> Unit) { launch = open }
|
||||
internal fun finish() { starting = null; launch = null }
|
||||
}
|
||||
|
||||
/** The hand-off is one black rectangle drawn over the page, never a second screen layer. */
|
||||
@Composable
|
||||
fun PlaybackHandoffVeil(start: PlaybackStart, modifier: Modifier = Modifier) {
|
||||
val pending = start.launch
|
||||
val reduced = reducedMotion()
|
||||
val alpha = remember { Animatable(0f) }
|
||||
LaunchedEffect(pending, reduced) {
|
||||
if (pending == null) {
|
||||
alpha.snapTo(0f)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
alpha.animateTo(1f, tween(motionDuration(M.base, reduced), easing = M.ease))
|
||||
start.finish()
|
||||
pending()
|
||||
}
|
||||
if (pending != null || alpha.value > 0f) Box(modifier.fillMaxSize().drawBehind { drawRect(Color.Black.copy(alpha = alpha.value)) })
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
||||
|
||||
/** Side panels enter over sheetIn and leave over sheetOut on the shared ease-out; reduced motion caps both at the fade limit. */
|
||||
@Composable
|
||||
internal fun rememberSheetFade(visible: Boolean, initial: Float = 0f): State<Float> {
|
||||
val reduced = reducedMotion()
|
||||
val progress = remember { Animatable(initial) }
|
||||
LaunchedEffect(visible, reduced) {
|
||||
progress.animateTo(if (visible) 1f else 0f,
|
||||
tween(motionDuration(if (visible) M.sheetIn else M.sheetOut, reduced), easing = M.easeOut))
|
||||
}
|
||||
return progress.asState()
|
||||
}
|
||||
|
||||
/** The scrim stays a draw-only colour, so a fading sheet never adds a full-screen layer. */
|
||||
internal fun Modifier.sheetScrim(progress: () -> Float): Modifier = drawBehind {
|
||||
val scrim = WolfskinTokens.Color.sheetScrim
|
||||
drawRect(scrim.copy(alpha = scrim.alpha * progress()))
|
||||
}
|
||||
|
||||
/** The bounded panel modulates each draw, so its outside shadow fades with it and no offscreen buffer is allocated. */
|
||||
internal fun Modifier.sheetPanelFade(progress: () -> Float): Modifier = graphicsLayer {
|
||||
alpha = progress()
|
||||
compositingStrategy = CompositingStrategy.ModulateAlpha
|
||||
}
|
||||
|
||||
/** Focus and selection colours retarget over focusMove on the ease-out; reduced motion caps the fade. */
|
||||
@Composable
|
||||
internal fun animatedFocusColor(target: Color, label: String = "focus-colour"): Color {
|
||||
val reduced = reducedMotion()
|
||||
return animateColorAsState(target, tween(motionDuration(M.focusMove, reduced), easing = M.easeOut), label = label).value
|
||||
}
|
||||
|
||||
/** A pending action keeps its button's resting width: the resting content fades out while the busy label fades in over base. */
|
||||
@Composable
|
||||
internal fun BusyLabelSwap(busy: Boolean, busyLabel: String, color: Color, style: TextStyle, content: @Composable () -> Unit) {
|
||||
val reduced = reducedMotion()
|
||||
val progress = remember { Animatable(if (busy) 1f else 0f) }
|
||||
LaunchedEffect(busy, reduced) {
|
||||
progress.animateTo(if (busy) 1f else 0f, tween(motionDuration(M.base, reduced), easing = M.ease))
|
||||
}
|
||||
val showLabel = busy || progress.value > 0f
|
||||
Layout(content = {
|
||||
Box(Modifier.graphicsLayer { alpha = 1f - progress.value }.then(if (busy) Modifier.clearAndSetSemantics { } else Modifier)) { content() }
|
||||
if (showLabel) Text(busyLabel, Modifier.graphicsLayer { alpha = progress.value }, color = color, style = style, maxLines = 1, softWrap = false)
|
||||
}) { measurables, constraints ->
|
||||
val resting = measurables[0].measure(constraints)
|
||||
val label = measurables.getOrNull(1)?.measure(Constraints())
|
||||
layout(resting.width, resting.height) {
|
||||
resting.place(0, 0)
|
||||
if (label != null) label.place((resting.width - label.width) / 2, (resting.height - label.height) / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,24 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.dropShadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.shadow.Shadow
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
|
|
@ -16,14 +26,31 @@ 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.Motion as M
|
||||
|
||||
/** Callers pass their current message; a cleared message fades out with its last text instead of vanishing. */
|
||||
@Composable
|
||||
fun RecoveryToastHost(message: String?, modifier: Modifier = Modifier) {
|
||||
val reduced = reducedMotion()
|
||||
var last by remember { mutableStateOf(message) }
|
||||
val opacity = remember { Animatable(0f) }
|
||||
SideEffect { if (message != null) last = message }
|
||||
LaunchedEffect(message != null, reduced) {
|
||||
opacity.animateTo(if (message != null) 1f else 0f, tween(motionDuration(M.base, reduced), easing = M.ease))
|
||||
}
|
||||
val text = message ?: last ?: return
|
||||
if (message == null && opacity.value == 0f) return
|
||||
RecoveryToast(text, modifier) { opacity.value }
|
||||
}
|
||||
|
||||
/** Recovery messages remain transient and never take focus from the action that failed. */
|
||||
@Composable
|
||||
fun RecoveryToast(message: String, modifier: Modifier = Modifier) {
|
||||
fun RecoveryToast(message: String, modifier: Modifier = Modifier, opacity: () -> Float = { 1f }) {
|
||||
Box(modifier.fillMaxSize()) {
|
||||
val shape = androidx.compose.runtime.remember { RoundedCornerShape(WolfskinTokens.Radius.panel) }
|
||||
val shape = remember { RoundedCornerShape(WolfskinTokens.Radius.panel) }
|
||||
Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, H.toastTop)
|
||||
.widthIn(max = H.toastWidth)
|
||||
.graphicsLayer { alpha = opacity(); compositingStrategy = CompositingStrategy.ModulateAlpha }
|
||||
.dropShadow(shape, Shadow(radius = H.toastShadowBlur, color = Color.Black.copy(alpha = H.toastShadowOpacity),
|
||||
offset = DpOffset(WolfskinTokens.Shell.zero, H.toastShadowY)))
|
||||
.background(C.surface2, shape)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -57,13 +55,12 @@ val SETTINGS_GROUPS = listOf(
|
|||
@Composable
|
||||
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") {
|
||||
val list = state.view.listState
|
||||
val reduced = reducedMotion()
|
||||
val density = LocalDensity.current
|
||||
LaunchedEffect(state.settingsRow, state.navFocused) {
|
||||
if (state.navFocused) return@LaunchedEffect
|
||||
val target = (state.settingsRow - WolfskinTokens.Update.settingsVisibleRows.toInt() + 1).coerceAtLeast(0)
|
||||
val distance = with(density) { (L.row_srow * (target - list.firstVisibleItemIndex)).toPx() } - list.firstVisibleItemScrollOffset
|
||||
list.animateScrollBy(distance, tween(if (reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut))
|
||||
animateFrameScroll(list, distance)
|
||||
}
|
||||
Box(modifier.fillMaxSize()) {
|
||||
Row(Modifier.offset(L.safeSide, WolfskinTokens.Update.headerTop).height(WolfskinTokens.Update.headerHeight),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
|
||||
/** Preserve one lookahead card and clamp short shelves and row ends to their real contents. */
|
||||
|
|
@ -15,7 +13,6 @@ internal fun shelfFirst(column: Int, previous: Int, slots: Int, count: Int): Int
|
|||
/** Reconcile the actual track on every focus transition, including interrupted or restored offsets. */
|
||||
internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) {
|
||||
val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset
|
||||
if (distance != 0f) animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.base,
|
||||
easing = WolfskinTokens.Motion.ease))
|
||||
animateFrameScroll(this, distance)
|
||||
if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,6 +229,8 @@ object WolfskinTokens {
|
|||
const val focusMove = 180
|
||||
const val captionDelay = 60
|
||||
const val captionOut = 100
|
||||
const val sheetIn = 160
|
||||
const val sheetOut = 100
|
||||
const val artReveal = 160
|
||||
const val heroFade = 320
|
||||
const val heroCopyOut = 120
|
||||
|
|
@ -319,6 +321,7 @@ object WolfskinTokens {
|
|||
val receiptPaddingX = 15.0.dp
|
||||
val receiptGap = 5.0.dp
|
||||
val receiptGlyph = 18.0.dp
|
||||
val thumbGlyph = 20.0.dp
|
||||
val buttonMargin = 6.0.dp
|
||||
val skeletonTitleWidth = 180.0.dp
|
||||
val skeletonTitleHeight = 17.0.dp
|
||||
|
|
|
|||
|
|
@ -0,0 +1,251 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import kotlin.math.abs
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Native ring pixels cover translation, shape morphing, scroll tracking and legacy parity. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class FocusGlideTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private val directory get() = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/E02").apply { mkdirs() }
|
||||
private val accentArgb = WolfskinTokens.Color.accent.toArgb()
|
||||
private var pixels = IntArray(0)
|
||||
|
||||
@Before fun fullscreen() {
|
||||
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
@Suppress("DEPRECATION")
|
||||
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
}
|
||||
|
||||
@Test fun shelfRightAndRowDownGlideAsOneShape() = shelves(false)
|
||||
@Test fun reducedShelfGeometrySnapsInOneFrame() = shelves(true)
|
||||
|
||||
private fun shelves(reduced: Boolean) {
|
||||
val row = mutableIntStateOf(0)
|
||||
val column = mutableIntStateOf(0)
|
||||
val memory = HomeViewMemory()
|
||||
val cards = List(5) { HomeTitle("$it", "Film $it", "movie") }
|
||||
val tiles = HomeRow("tiles", HomeRowKind.Shelf, cards = cards, cardKind = HomeCardKind.Tile)
|
||||
val posters = tiles.copy(id = "posters", cardKind = HomeCardKind.Poster)
|
||||
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
|
||||
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(WolfskinTokens.Color.bg)) {
|
||||
FocusRingHost { Column(Modifier.padding(top = 40.dp)) {
|
||||
HomeShelf(tiles, memory, row.intValue == 0, column.intValue, emptyList(), {}, {})
|
||||
HomeShelf(posters, memory, row.intValue == 1, column.intValue, emptyList(), {}, {})
|
||||
} }
|
||||
}
|
||||
} } }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.mainClock.autoAdvance = false
|
||||
val suffix = if (reduced) "reduced/" else ""
|
||||
val samples = mutableListOf<String>()
|
||||
fun frame(phase: String, index: Int): Rect {
|
||||
val image = capture("$suffix$phase-${index.toString().padStart(2, '0')}")
|
||||
val bounds = accentBounds(image)
|
||||
val runs = edgeRuns(image, bounds.centerY())
|
||||
samples += "$phase,${index * 16},${bounds.left},${bounds.top},${bounds.width()},${bounds.height()},$runs"
|
||||
image.recycle()
|
||||
return bounds
|
||||
}
|
||||
val start = frame("shelf-right", 0)
|
||||
commit { column.intValue = 1 }
|
||||
val right = List(21) { index -> step(); frame("shelf-right", index + 1) }
|
||||
commit { row.intValue = 1 }
|
||||
val down = List(21) { index -> step(); frame("row-down", index) }
|
||||
commit { row.intValue = -1 }
|
||||
val fadeBudget = if (reduced) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.focusMove
|
||||
repeat((fadeBudget + 15) / 16 + 1) { step() }
|
||||
val hidden = capture("${suffix}nav-exit")
|
||||
assertTrue("Leaving cards fades the ring completely", accentBounds(hidden).isEmpty)
|
||||
val lastRing = down.last()
|
||||
val background = WolfskinTokens.Color.bg.toArgb()
|
||||
for (y in lastRing.top - 3..lastRing.top + 7) for (shift in listOf(0, 8, 16)) {
|
||||
val pixel = hidden.getPixel(lastRing.centerX(), y)
|
||||
assertTrue("No residual ring alpha after the fade deadline",
|
||||
abs((pixel ushr shift and 255) - (background ushr shift and 255)) <= 2)
|
||||
}
|
||||
hidden.recycle()
|
||||
File(directory, "${suffix}samples.csv").writeText("phase,timeMs,left,top,width,height,edgeRuns\n" + samples.joinToString("\n"))
|
||||
assertTrue("Ring must travel to the next card", right.last().left > start.left)
|
||||
monotonic(listOf(start.left) + right.map { it.left })
|
||||
assertTrue("Nav spring must be visually settled by 300ms", abs(right.last().left - right[18].left) <= 2)
|
||||
monotonic(listOf(start.width()) + down.map { it.width() })
|
||||
monotonic(listOf(start.height()) + down.map { it.height() })
|
||||
if (reduced) {
|
||||
assertEquals("Reduced horizontal move snaps on frame one", right.last(), right.first())
|
||||
assertEquals("Reduced shape change snaps on frame one", down.last(), down.first())
|
||||
} else {
|
||||
assertTrue("Horizontal move must glide, not jump", right.first().left < right.last().left)
|
||||
assertTrue("Width must morph", down.first().width() != down.last().width())
|
||||
assertTrue("Height must morph", down.first().height() != down.last().height())
|
||||
}
|
||||
samples.filter { it.startsWith("shelf-right") }.forEach { assertEquals("Exactly one ring crosses its midline: $it", 2, it.substringAfterLast(',').toInt()) }
|
||||
}
|
||||
|
||||
@Test fun circleRadiusMorphsOnTheNavSpring() = circleRadius(false)
|
||||
@Test fun reducedCircleRadiusSnapsInOneFrame() = circleRadius(true)
|
||||
|
||||
private fun circleRadius(reduced: Boolean) {
|
||||
val selected = mutableIntStateOf(0)
|
||||
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
|
||||
Box(Modifier.requiredSize(320.dp).background(WolfskinTokens.Color.bg)) {
|
||||
FocusRingHost {
|
||||
for (index in 0..1) key(index) {
|
||||
val radius = if (index == 0) WolfskinTokens.Radius.card else 60.dp
|
||||
Box(Modifier.offset(80.dp, 80.dp).size(120.dp)
|
||||
.focusScale(selected.intValue == index, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(selected.intValue == index, radius, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
|
||||
.background(WolfskinTokens.Color.surface, androidx.compose.foundation.shape.RoundedCornerShape(radius)))
|
||||
}
|
||||
}
|
||||
}
|
||||
} } }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.mainClock.autoAdvance = false
|
||||
fun topSpan(name: String): Int {
|
||||
val bitmap = capture(name)
|
||||
val bounds = accentBounds(bitmap)
|
||||
val pixels = (bounds.left until bounds.right).filter { accent(bitmap.getPixel(it, bounds.top + 3)) }
|
||||
bitmap.recycle()
|
||||
return if (pixels.isEmpty()) 0 else pixels.last() - pixels.first() + 1
|
||||
}
|
||||
val prefix = if (reduced) "reduced/circle" else "circle"
|
||||
val start = topSpan("$prefix-start")
|
||||
commit { selected.intValue = 1 }
|
||||
val spans = List(22) { step(); topSpan("$prefix-$it") }
|
||||
File(directory, "$prefix-samples.csv").writeText("timeMs,topEdgeSpanPx\n0,$start\n" +
|
||||
spans.mapIndexed { index, span -> "${(index + 1) * 16},$span" }.joinToString("\n"))
|
||||
assertTrue("Radius change must change corner pixels", start > spans.last() + 20)
|
||||
monotonic(listOf(start) + spans)
|
||||
if (reduced) assertEquals(spans.last(), spans.first())
|
||||
else assertTrue("Radius must morph, not jump", spans.first() > spans.last())
|
||||
}
|
||||
|
||||
@Test fun posterTileAndSeeAllSettledRingsMatchLegacyPixels() {
|
||||
val hosted = mutableStateOf(false)
|
||||
val kind = mutableIntStateOf(0)
|
||||
rule.setContent { WolfskinTheme {
|
||||
Box(Modifier.requiredSize(500.dp, 320.dp).background(WolfskinTokens.Color.bg)) {
|
||||
@Composable fun card() {
|
||||
val width = if (kind.intValue == 1) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
|
||||
val height = if (kind.intValue == 1) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH
|
||||
val modifier = Modifier.offset(60.dp, 40.dp)
|
||||
if (kind.intValue == 2) LibrarySeeAllCard(40, true, {}, modifier, width, height, HomeType.listTitle, HomeType.label)
|
||||
else TitleCard(true, modifier, width, height) { Box(Modifier.fillMaxSize().background(WolfskinTokens.Color.surface)) }
|
||||
}
|
||||
if (hosted.value) FocusRingHost { card() } else card()
|
||||
}
|
||||
} }
|
||||
for (shape in 0..2) {
|
||||
rule.runOnIdle { hosted.value = false; kind.intValue = shape }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
val legacy = capture("legacy-$shape")
|
||||
rule.runOnIdle { hosted.value = true }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
val shared = capture("settled-$shape")
|
||||
assertEquals("Settled ring bounds for shape $shape", accentBounds(legacy), accentBounds(shared))
|
||||
var maximumDifference = 0
|
||||
for (y in 0 until legacy.height) for (x in 0 until legacy.width) {
|
||||
val expected = legacy.getPixel(x, y)
|
||||
val actual = shared.getPixel(x, y)
|
||||
for (channel in 0..3) {
|
||||
val shift = channel * 8
|
||||
maximumDifference = maxOf(maximumDifference, abs((expected ushr shift and 255) - (actual ushr shift and 255)))
|
||||
}
|
||||
}
|
||||
assertTrue("Settled shape $shape differs by $maximumDifference channel levels", maximumDifference <= 2)
|
||||
legacy.recycle(); shared.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun libraryRingTracksFrameScrollAndRapidRetargets() = library(false)
|
||||
@Test fun reducedLibraryRingTracksFrameScrollAndRapidRetargets() = library(true)
|
||||
|
||||
private fun library(reduced: Boolean) {
|
||||
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("movies"); navFocused = false }
|
||||
val grid = LibraryGridState("Movies", List(60) { LibraryTitle("$it", "Film $it", "movie") })
|
||||
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme { FocusRingHost { LibraryScreen(shell, grid, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) } } } }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.mainClock.autoAdvance = false
|
||||
rule.runOnIdle { grid.key(ShellKey.Down) }
|
||||
repeat(3) { step() }
|
||||
rule.runOnIdle { grid.key(ShellKey.Right) }
|
||||
repeat(3) { step() }
|
||||
rule.runOnIdle { grid.key(ShellKey.Down) }
|
||||
repeat(30) { step() }
|
||||
val image = capture(if (reduced) "reduced/library-settled" else "library-settled")
|
||||
val ring = accentBounds(image)
|
||||
image.recycle()
|
||||
assertFalse(ring.isEmpty)
|
||||
assertEquals("Scroll target stays on the pinned line", WolfskinTokens.Layout.pinnedLine.value * 2 - WolfskinTokens.Card.posterH.value * (WolfskinTokens.Focus.scaleCard - 1f), ring.top.toFloat(), 5f)
|
||||
}
|
||||
|
||||
private fun commit(action: () -> Unit) = rule.runOnIdle { action(); Snapshot.sendApplyNotifications() }
|
||||
private fun step() { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() }
|
||||
private fun capture(name: String): Bitmap {
|
||||
val file = File(directory, "$name.png").apply { parentFile!!.mkdirs() }
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
return BitmapFactory.decodeFile(file.absolutePath)
|
||||
}
|
||||
private fun accent(pixel: Int): Boolean {
|
||||
for (channel in 0..2) {
|
||||
val shift = channel * 8
|
||||
if (abs((pixel ushr shift and 255) - (accentArgb ushr shift and 255)) > 8) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
private fun accentBounds(image: Bitmap): Rect {
|
||||
val bounds = Rect(image.width, image.height, 0, 0)
|
||||
val count = image.width * image.height
|
||||
if (pixels.size < count) pixels = IntArray(count)
|
||||
image.getPixels(pixels, 0, image.width, 0, 0, image.width, image.height)
|
||||
var index = 0
|
||||
for (y in 0 until image.height) for (x in 0 until image.width) if (accent(pixels[index++])) {
|
||||
bounds.left = minOf(bounds.left, x); bounds.top = minOf(bounds.top, y)
|
||||
bounds.right = maxOf(bounds.right, x + 1); bounds.bottom = maxOf(bounds.bottom, y + 1)
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
private fun edgeRuns(image: Bitmap, y: Int): Int {
|
||||
var runs = 0
|
||||
var inside = false
|
||||
for (x in 0 until image.width) {
|
||||
val next = accent(image.getPixel(x, y))
|
||||
if (next && !inside) runs++
|
||||
inside = next
|
||||
}
|
||||
return runs
|
||||
}
|
||||
private fun monotonic(values: List<Int>) {
|
||||
val direction = if (values.last() >= values.first()) 1 else -1
|
||||
values.zipWithNext().forEach { (before, after) -> assertTrue("Geometry reverses or overshoots: $values", (after - before) * direction >= -1) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.BitmapFactory
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Native toast pixels prove retention, interruption and bounded reduced-motion fades. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class RecoveryToastMotionTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun toastFadesInHoldsAndFadesOutWithoutStacking() = exercise(false)
|
||||
@Test fun reducedToastFadesInHoldsAndFadesOutWithoutStacking() = exercise(true)
|
||||
|
||||
private fun exercise(reduced: Boolean) {
|
||||
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
@Suppress("DEPRECATION")
|
||||
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
val message = mutableStateOf<String?>(null)
|
||||
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
|
||||
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(WolfskinTokens.Color.bg)) {
|
||||
RecoveryToastHost(message.value)
|
||||
}
|
||||
} } }
|
||||
rule.mainClock.autoAdvance = false
|
||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/P02${if (reduced) "/reduced" else ""}").apply { mkdirs() }
|
||||
val samples = mutableListOf<String>()
|
||||
var sampleX = 0
|
||||
var sampleY = 0
|
||||
fun frame(phase: String, index: Int): Int {
|
||||
val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||
val pixel = bitmap.getPixel(sampleX, sampleY)
|
||||
bitmap.recycle()
|
||||
val brightness = android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
|
||||
samples += "$phase,${index * 16},$brightness"
|
||||
return brightness
|
||||
}
|
||||
fun step() { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() }
|
||||
fun count() = rule.onAllNodesWithTag("recovery-toast", useUnmergedTree = true).fetchSemanticsNodes().size
|
||||
fun show(value: String?) = rule.runOnIdle { message.value = value; Snapshot.sendApplyNotifications() }
|
||||
show(RecoveryMessage.StillUnreachable)
|
||||
step()
|
||||
var inserted = 0
|
||||
while (count() == 0 && inserted++ < 3) step()
|
||||
val bounds = rule.onNodeWithTag("recovery-toast", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||
sampleX = (bounds.left + 40).toInt()
|
||||
sampleY = (bounds.center.y - 6).toInt()
|
||||
val entering = List(14) { index -> frame("toast-in", index).also { step() } }
|
||||
assertTrue("Fade-in must change native toast pixels: $entering", entering.last() > entering.first())
|
||||
entering.zipWithNext().forEach { (before, after) -> assertTrue("Fade-in reverses: $entering", after >= before - 1) }
|
||||
val deadline = (if (reduced) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.base) / 16 + 1
|
||||
assertEquals("Fade-in must settle by its token deadline", entering.last(), entering[deadline])
|
||||
rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.toast.toLong())
|
||||
assertEquals(entering.last(), frame("hold", 0))
|
||||
show(RecoveryMessage.PlaybackUnavailable)
|
||||
step()
|
||||
assertEquals(1, count())
|
||||
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertExists()
|
||||
assertEquals("Replacing a visible message must not restart its fade", entering.last(), frame("replace", 0))
|
||||
show(RecoveryMessage.StillUnreachable)
|
||||
step()
|
||||
show(null)
|
||||
step()
|
||||
assertEquals("Retain the row while fading out", 1, count())
|
||||
val leaving = List(14) { index -> frame("toast-out", index).also { step() } }
|
||||
assertTrue("Fade-out must change native toast pixels: $leaving", leaving.first() > leaving.last())
|
||||
leaving.zipWithNext().forEach { (before, after) -> assertTrue("Fade-out reverses: $leaving", after <= before + 1) }
|
||||
assertEquals(leaving.last(), leaving[deadline])
|
||||
rule.onNodeWithTag("recovery-toast").assertDoesNotExist()
|
||||
show(RecoveryMessage.StillUnreachable)
|
||||
repeat(14) { step() }
|
||||
show(null)
|
||||
step(); step(); step()
|
||||
val interrupted = frame("interrupted", 0)
|
||||
assertTrue(interrupted < entering.last())
|
||||
show(RecoveryMessage.StillUnreachable)
|
||||
step()
|
||||
val resumed = List(14) { index -> frame("resume", index).also { step() } }
|
||||
// Recomposition may consume one final outgoing frame before retargeting.
|
||||
assertTrue("Re-entry retains current opacity instead of resetting or snapping",
|
||||
resumed.first() >= leaving[3] - 1 && resumed.first() <= interrupted + 1)
|
||||
resumed.zipWithNext().forEach { (before, after) -> assertTrue(after >= before - 1) }
|
||||
assertEquals(entering.last(), resumed.last())
|
||||
assertEquals(1, count())
|
||||
File(directory, "samples.csv").writeText("phase,timeMs,brightness\n" + samples.joinToString("\n"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Held keys exercise real shelf/frame targets and measure velocity across interrupted scrolls. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class ScrollMomentumTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun heldRightCarriesVelocityAndSettlesOnShelfFirst() = exercise(false, false)
|
||||
@Test fun heldDownCarriesVelocityAndSettlesOnLibraryRow() = exercise(false, true)
|
||||
@Test fun reducedShelfScrollSnapsInOneFrame() = exercise(true, false)
|
||||
@Test fun reducedLibraryScrollSnapsInOneFrame() = exercise(true, true)
|
||||
|
||||
private fun exercise(reduced: Boolean, library: Boolean) {
|
||||
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
@Suppress("DEPRECATION")
|
||||
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
val shell = ShellState(fixturePeople(), "justin").apply {
|
||||
pickerVisible = false
|
||||
if (library) enterDestination("movies")
|
||||
navFocused = false
|
||||
}
|
||||
val row = HomeRow("long", HomeRowKind.Shelf, cards = List(30) { HomeTitle("$it", "Film $it", "movie") })
|
||||
shell.view.cards[row.id] = 4
|
||||
val grid = LibraryGridState("Movies", List(120) { LibraryTitle("$it", "Film $it", "movie") })
|
||||
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
|
||||
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
|
||||
if (library) LibraryScreen(shell, grid, modifier)
|
||||
else HomeScreen(shell, HomeSnapshot(listOf(row)), modifier) {}
|
||||
}
|
||||
} } }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.mainClock.autoAdvance = false
|
||||
val track = if (library) shell.view.listState else shell.view.home.tracks.getValue(row.id)
|
||||
val pitch = (WolfskinTokens.Card.posterW + WolfskinTokens.Card.posterGap).value * 2
|
||||
val framePitch = (WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand + WolfskinTokens.Layout.gridRowGap).value * 2
|
||||
fun offset(): Float {
|
||||
val first = track.firstVisibleItemIndex
|
||||
if (!library) return first * pitch + track.firstVisibleItemScrollOffset
|
||||
val top = if (first == 0) 0f else WolfskinTokens.Layout.row_lhead.value * 2 +
|
||||
if (first < 2) 0f else WolfskinTokens.Layout.row_chipsGrid.value * 2 + framePitch * (first - 2)
|
||||
return top + track.firstVisibleItemScrollOffset
|
||||
}
|
||||
fun target(): Float = if (!library) shell.view.home.firstCards.getValue(row.id) * pitch else
|
||||
if (grid.index / grid.columns == 0) 0f else (WolfskinTokens.Layout.row_lhead.value + WolfskinTokens.Layout.row_chipsGrid.value - WolfskinTokens.Layout.pinnedLine.value) * 2 + framePitch * (grid.index / grid.columns)
|
||||
fun press() = rule.runOnIdle { shell.key(if (library) ShellKey.Down else ShellKey.Right); Snapshot.sendApplyNotifications() }
|
||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/E05${if (library) "/library" else ""}${if (reduced) "/reduced" else ""}").apply { mkdirs() }
|
||||
val samples = mutableListOf<String>()
|
||||
val offsets = mutableListOf<Float>()
|
||||
val velocities = mutableListOf<Float>()
|
||||
val repeats = mutableListOf<Int>()
|
||||
press()
|
||||
// Start sampling at the animation's zero-time frame, after composition commits the new focus.
|
||||
repeat(2) { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() }
|
||||
val singleTarget = target()
|
||||
for (index in 0..80) {
|
||||
if (index >= 32 && index <= 47 && (index - 32) % 3 == 0) { repeats += index; press() }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
rule.waitForIdle()
|
||||
val position = offset()
|
||||
offsets += position
|
||||
velocities += if (index == 0) position / .016f else (position - offsets[index - 1]) / .016f
|
||||
samples += "${(index + 1) * 16},$position,${velocities.last()},${target()}"
|
||||
val file = File(directory, "held-${if (library) "down" else "right"}-${index.toString().padStart(2, '0')}.png")
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
}
|
||||
File(directory, "samples.csv").writeText("timeMs,offsetPx,velocityPxPerSecond,targetPx\n" + samples.joinToString("\n"))
|
||||
offsets.zipWithNext().forEach { (before, after) -> assertTrue("Track reversed: $offsets", after >= before) }
|
||||
assertTrue("No overshoot of final target", offsets.all { it <= target() + 1 })
|
||||
assertEquals("Final target must reconcile exactly", target(), offsets.last(), 1f)
|
||||
assertTrue("Single press must reach 95 percent by 200ms: $offsets", offsets[12] >= singleTarget * .95f)
|
||||
assertEquals("Single press is visually settled by 320ms", singleTarget, offsets[19], 2f)
|
||||
if (reduced) assertEquals("Reduced scroll lands in one frame", singleTarget, offsets.first(), 1f)
|
||||
else repeats.drop(2).forEach { index ->
|
||||
assertTrue("Repeated key must not stop the track", velocities[index] > 0f)
|
||||
assertTrue("Retarget must carry velocity instead of restarting: ${velocities.subList(index - 1, index + 2)}", velocities[index + 1] >= velocities[index - 1] * .5f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun cancelledScrollCannotReuseAnAbandonedFrameClock() = cancelledScroll(false)
|
||||
@Test fun reducedCancelledScrollStillSnapsOnReentry() = cancelledScroll(true)
|
||||
|
||||
private fun cancelledScroll(reduced: Boolean) {
|
||||
val visible = mutableStateOf(true)
|
||||
val target = mutableFloatStateOf(0f)
|
||||
val list = LazyListState()
|
||||
fun offset() = list.firstVisibleItemIndex * 200f + list.firstVisibleItemScrollOffset
|
||||
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
|
||||
if (visible.value) {
|
||||
LaunchedEffect(target.floatValue) { animateFrameScroll(list, target.floatValue - offset()) }
|
||||
LazyRow(Modifier.requiredSize(500.dp, 100.dp), state = list) {
|
||||
items(30) { Box(Modifier.size(100.dp)) }
|
||||
}
|
||||
} else Box(Modifier.requiredSize(500.dp, 100.dp))
|
||||
} } }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.mainClock.autoAdvance = false
|
||||
rule.runOnIdle { target.floatValue = 600f; Snapshot.sendApplyNotifications() }
|
||||
repeat(5) { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() }
|
||||
rule.runOnIdle { visible.value = false; Snapshot.sendApplyNotifications() }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
rule.waitForIdle()
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
val resumedTarget = offset() + 600f
|
||||
rule.runOnIdle { target.floatValue = resumedTarget; visible.value = true; Snapshot.sendApplyNotifications() }
|
||||
repeat(3) { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() }
|
||||
if (reduced) assertEquals(resumedTarget, offset(), 1f)
|
||||
else assertTrue("An abandoned frame timestamp must not turn a fresh scroll into a snap", offset() < resumedTarget - 300f)
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
assertEquals(resumedTarget, offset(), 1f)
|
||||
}
|
||||
}
|
||||
|
|
@ -142,6 +142,8 @@
|
|||
--motion-focusMove: 180ms;
|
||||
--motion-captionDelay: 60ms;
|
||||
--motion-captionOut: 100ms;
|
||||
--motion-sheetIn: 160ms;
|
||||
--motion-sheetOut: 100ms;
|
||||
--motion-artReveal: 160ms;
|
||||
--motion-heroFade: 320ms;
|
||||
--motion-heroCopyOut: 120ms;
|
||||
|
|
@ -192,6 +194,7 @@
|
|||
--requests-receiptPaddingX: 30.0px;
|
||||
--requests-receiptGap: 10.0px;
|
||||
--requests-receiptGlyph: 36.0px;
|
||||
--requests-thumbGlyph: 40.0px;
|
||||
--requests-buttonMargin: 12.0px;
|
||||
--requests-skeletonTitleWidth: 360.0px;
|
||||
--requests-skeletonTitleHeight: 34.0px;
|
||||
|
|
|
|||
|
|
@ -1408,6 +1408,8 @@
|
|||
"focusMove": { "$type": "duration", "$value": "180ms" },
|
||||
"captionDelay": { "$type": "duration", "$value": "60ms" },
|
||||
"captionOut": { "$type": "duration", "$value": "100ms" },
|
||||
"sheetIn": { "$type": "duration", "$value": "160ms", "source": "ui-polish P06, approved 2026-11-07: side panel and its scrim fade in, easeOut" },
|
||||
"sheetOut": { "$type": "duration", "$value": "100ms", "source": "ui-polish P06, approved 2026-11-07: side panel and its scrim fade out, easeOut" },
|
||||
"artReveal": { "$type": "duration", "$value": "160ms" },
|
||||
"heroFade": { "$type": "duration", "$value": "320ms" },
|
||||
"heroCopyOut": { "$type": "duration", "$value": "120ms" },
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
|
||||
993d933950e17fb72d0902ffc440e1e4ed4dc088c96a8d12974431c77dc31634 generate_tokens.py
|
||||
72b67ad9ba95e7bbc17cec29cc676b06b7e9d81c546022b5a100d6d4e2a27c64 home-tokens.json
|
||||
192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json
|
||||
42399062a6a22566e26adb71eaa4052b28be93d28d0eb46d9d4b87f3142a2944 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
9562bd6e6d8fb4d6ef380380e621d6c32401387560437eb4f999ebecbcda160e shell-tokens.json
|
||||
68a94b81bb0300aa05d241f11d88ff4843caa0b1b16e0ed09f62674b336e69fe src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
98fa6414cc6663624d09329615cdf0e6599baec570a8d878f5214c95cb33cb48 title-tokens.json
|
||||
731ff25d6ec3ba12d0840523a1ebd65a81b3474cf74040f13bbd1f54985a3053 tokens.css
|
||||
12d33b7b5740443e068cbb06528d88c42ad3bb1df0d4d45dd6ee6797d3dfc401 tokens.json
|
||||
e93d868d6fdf9ad939f1fd18c452732fb3afa76bbba8fe6fdd0c1007aeff3b01 tokens.css
|
||||
e729efd2efd6bc52a35480038d64ff0199ce166e6e794332e9cfa7dc84ed98c9 tokens.json
|
||||
19728f99b90bc40c0ebec37c5bcc45b383b89b023211490bb3235c1690237ae5 update-tokens.json
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue