Candidate 3: merge polish-lane-c

This commit is contained in:
root 2026-11-08 06:50:17 +01:00
commit aa470b96b6
20 changed files with 1204 additions and 84 deletions

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
@ -17,9 +18,13 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.HomeType as T
/** Home's pending playback belongs to the app session rather than a transient hero composition. */
val LocalHomePlaybackStart = compositionLocalOf<PlaybackStart?> { null }
/** The hero occupies one row while its backdrop extends beneath the first shelves. */
@Composable
fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRating>, onButton: (Int) -> Unit, onHover: (Int) -> Unit) {
val start = LocalHomePlaybackStart.current
HeroMotionScope {
Box(Modifier.fillMaxWidth().height(L.row_hero)) {
if (!row.skeleton) {
@ -57,7 +62,8 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
content = {
val primary = (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
PickerButton(primary, focused && row.watchReady && column == 0, true, "playback",
onFocus = { if (row.watchReady) onHover(0) }, textStyle = T.button) { if (row.watchReady) onButton(0) }
onFocus = { if (row.watchReady) onHover(0) }, textStyle = T.button,
busy = start?.starting == "hero-play", busyLabel = "Starting…") { if (row.watchReady) onButton(0) }
},
) { measurables, constraints ->
val button = measurables.single().measure(constraints)

View file

@ -0,0 +1,71 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.semantics.clearAndSetSemantics
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
/** Frozen public identity data and a frame-clock origin survive replacement of the signed-in app. */
@Stable
class PersonCommit(val people: List<ShellPerson>, val chosenIndex: Int, val chosenKey: String) {
var startFrameNanos: Long? = null
internal set
internal var elapsedMillis by mutableFloatStateOf(0f)
var timelineComplete by mutableStateOf(false)
internal set
var switchEnded by mutableStateOf(false)
var appReplacementPending by mutableStateOf(false)
var fallback: (() -> Unit)? = null
}
/** The frozen picker is opaque and inert; only the covering rectangles change while switching identity. */
@Composable
fun PersonCommitVeil(commit: PersonCommit, modifier: Modifier = Modifier) {
val reduced = reducedMotion()
val duration = motionDuration(M.base, reduced)
val total = if (reduced) duration else duration * 2
LaunchedEffect(commit, total) {
while (!commit.timelineComplete) {
withFrameNanos { frame ->
val start = commit.startFrameNanos ?: frame.also { commit.startFrameNanos = it }
commit.elapsedMillis = (frame - start) / 1_000_000f
if (commit.elapsedMillis >= total) commit.timelineComplete = true
}
}
}
Box(modifier.fillMaxSize().background(C.bg).clearAndSetSemantics { }
.pointerInput(commit) { awaitPointerEventScope { while (true) awaitPointerEvent().changes.forEach { it.consume() } } }
.drawWithContent {
drawContent()
val elapsed = commit.elapsedMillis
val full = if (commit.timelineComplete) 1f else if (reduced) elapsed / duration else (elapsed - duration) / duration
drawRect(C.bg.copy(alpha = M.ease.transform(full.coerceIn(0f, 1f))))
// Paint the opaque phase-one masks last: phase two must not round their background pixels upward.
if (!reduced) {
val alpha = M.ease.transform((elapsed / duration).coerceIn(0f, 1f))
val width = L.pickerAvatar.toPx()
val gap = L.pickerGap.toPx()
val left = (size.width - width * commit.people.size - gap * (commit.people.size - 1)) / 2
commit.people.indices.forEach { index ->
if (index != commit.chosenIndex) drawRect(C.bg.copy(alpha = alpha),
topLeft = Offset(left + index * (width + gap) - gap / 2, S.pickerPeopleTop.toPx() - gap / 2),
size = Size(width + gap, S.pickerGearTop.toPx() - S.pickerPeopleTop.toPx() + gap / 2))
}
}
}) {
Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker))
PickerPeople(commit.people, commit.chosenIndex, false, onPerson = null, onGear = null)
}
}

View file

@ -37,15 +37,23 @@ fun WhosWatching(state: ShellState, modifier: Modifier = Modifier) {
@Composable
private fun PeoplePicker(state: ShellState) {
PickerPeople(state.people, state.pickerIndex, state.gearFocused,
onPerson = { index -> state.focusPerson(index); state.key(ShellKey.Ok) },
onGear = { state.gearFocused = true; state.key(ShellKey.Ok) })
}
/** Live and frozen pickers share geometry; a frozen picker has no interactive modifiers. */
@Composable
internal fun PickerPeople(people: List<ShellPerson>, focusedIndex: Int, gearFocused: Boolean,
onPerson: ((Int) -> Unit)?, onGear: (() -> Unit)?) {
Box(Modifier.fillMaxWidth().offset(y = S.pickerTitleTop), contentAlignment = Alignment.TopCenter) {
Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle)
}
Row(Modifier.fillMaxWidth().offset(y = S.pickerPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) {
state.people.forEachIndexed { index, person ->
val focused = !state.gearFocused && state.pickerIndex == index
Column(Modifier.width(L.pickerAvatar).testTag("picker-person:${person.key}").focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable {
state.focusPerson(index); state.key(ShellKey.Ok)
}, horizontalAlignment = Alignment.CenterHorizontally) {
people.forEachIndexed { index, person ->
val focused = !gearFocused && focusedIndex == index
Column(Modifier.width(L.pickerAvatar).testTag("picker-person:${person.key}").focusScale(focused, WolfskinTokens.Focus.scaleCard)
.then(if (onPerson != null) Modifier.clickable { onPerson(index) } else Modifier), horizontalAlignment = Alignment.CenterHorizontally) {
Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker,
Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerRing))
Spacer(Modifier.height(S.pickerNameGap))
@ -54,11 +62,11 @@ private fun PeoplePicker(state: ShellState) {
}
}
val left = (S.stageWidth - L.gear) / 2
Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).testTag("picker-settings").focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard)
.focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing)
.clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) {
Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon))
if (state.gearFocused) {
Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).testTag("picker-settings").focusScale(gearFocused, WolfskinTokens.Focus.scaleCard)
.focusRing(gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing)
.then(if (onGear != null) Modifier.clickable(onClick = onGear) else Modifier), contentAlignment = Alignment.Center) {
Glyph("gear", if (gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon))
if (gearFocused) {
Box(Modifier.align(Alignment.TopCenter).offset(y = S.pickerGearCaptionTop).requiredWidth(L.pickerAvatar), contentAlignment = Alignment.TopCenter) {
Text("Settings", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, softWrap = false)
}
@ -74,7 +82,7 @@ private fun RecoveryPicker(state: ShellState) {
Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle)
}
if (loading) {
Row(Modifier.fillMaxWidth().offset(y = S.pickerLoadingPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) {
Row(Modifier.fillMaxWidth().offset(y = S.pickerLoadingPeopleTop).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) {
repeat(state.people.size) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(Modifier.size(L.pickerAvatar).background(C.surface2, CircleShape))
@ -103,16 +111,20 @@ 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,
textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button, onClick: () -> Unit) {
textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button,
busy: Boolean = false, busyLabel: String? = null, 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.ringControlRing, FocusLift.Button)
.then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier)
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick)
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable { if (!busy) onClick() }
.padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Home.plateTop)) {
if (icon != null) Glyph(icon, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon))
Text(label, modifier = Modifier.exactLineBox(textStyle), color = if (primary) C.onFocus else C.fg, style = textStyle)
val content: @Composable () -> Unit = {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Home.plateTop)) {
if (icon != null) Glyph(icon, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon))
Text(label, modifier = Modifier.exactLineBox(textStyle), color = if (primary) C.onFocus else C.fg, style = textStyle)
}
}
if (busyLabel == null) content() else BusyLabelSwap(busy, busyLabel, if (primary) C.onFocus else C.fg, textStyle, content)
}
}

View file

@ -0,0 +1,63 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.BitmapFactory
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
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
/** Loading profiles share one pulse without fading the recovery action. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class PickerPolishTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun loadingAvatarsBreatheButtonStays() = capture(false)
@Test fun reducedMotionKeepsAvatarsAndButtonStill() = capture(true)
private fun capture(reduced: Boolean) {
rule.mainClock.autoAdvance = false
val state = ShellState(fixturePeople(), "justin").apply { peopleStatus = PeopleStatus.Loading }
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
} } }
rule.mainClock.advanceTimeBy(32)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/P07/${if (reduced) "reduced" else "normal"}").apply { mkdirs() }
val samples = (0..14).map { index ->
val file = File(directory, "picker-loading-${index.toString().padStart(2, '0')}.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val image = BitmapFactory.decodeFile(file.absolutePath)
val pixels = try { image.getPixel(650, 540) to image.getPixel(950, 820) } finally { image.recycle() }
rule.mainClock.advanceTimeBy(100, ignoreFrameDuration = true)
"${index * 100},${pixels.first},${pixels.second}"
}
File(directory, "samples.csv").writeText("timeMs,avatar,button\n" + samples.joinToString("\n"))
val avatars = samples.map { it.split(',')[1].toInt() }
val buttons = samples.map { it.split(',')[2].toInt() }
assertEquals("The recovery button never pulses", 1, buttons.distinct().size)
if (reduced) assertEquals("Reduced motion stops the shared pulse", 1, avatars.distinct().size)
else {
assertNotEquals("The avatar must breathe by the middle of the cycle", avatars.first(), avatars[7])
assertNotEquals("The avatar must also pass through an intermediate alpha", avatars.first(), avatars[3])
}
rule.runOnIdle { state.peopleStatus = PeopleStatus.Ready; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeBy(1000)
rule.onNodeWithTag("picker-person:justin").assertIsDisplayed()
rule.onNodeWithText("Continue as Justin").assertDoesNotExist()
assertTrue("Removing the last subscriber stops its infinite transition",
androidx.compose.runtime.Recomposer.runningRecomposers.value.all { !it.hasPendingWork })
}
}