Polish picker switching and Home playback handoff
This commit is contained in:
parent
74b737fb52
commit
612ae79f0c
20 changed files with 974 additions and 83 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
// 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
|
||||
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))
|
||||
}
|
||||
}
|
||||
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))))
|
||||
}) {
|
||||
Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker))
|
||||
PickerPeople(commit.people, commit.chosenIndex, false, onPerson = null, onGear = null)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue