Show episode ratings and animate title readiness without empty space
All checks were successful
review/code-claude Reviewed e6ddf1d7: his board picks implemented, no blocking finding
review/design-claude Reviewed e6ddf1d7: his board picks implemented, no blocking finding
All checks were successful
review/code-claude Reviewed e6ddf1d7: his board picks implemented, no blocking finding
review/design-claude Reviewed e6ddf1d7: his board picks implemented, no blocking finding
This commit is contained in:
parent
174e1ae5c9
commit
e6ddf1d7e5
8 changed files with 159 additions and 23 deletions
|
|
@ -1,5 +1,9 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.platform.testTag
|
||||
|
|
@ -35,6 +39,11 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
|||
fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) {
|
||||
if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return }
|
||||
val item = state.data.item
|
||||
val factsItem = state.data.watchTarget.takeIf { it.kind == "episode" } ?: item
|
||||
val readinessHeight by animateDpAsState(
|
||||
if (state.data.requestedStage == null) WolfskinTokens.Shell.zero else WolfskinTokens.Motion.readinessSlot,
|
||||
tween(if (reducedMotion()) 0 else WolfskinTokens.Motion.lateLineFade, easing = WolfskinTokens.Motion.easeOut),
|
||||
label = "title-readiness-height")
|
||||
val episode = when {
|
||||
state.row == "episodes" -> state.data.episodes.getOrNull(state.column)
|
||||
state.data.watchTarget.kind == "episode" -> state.data.watchTarget
|
||||
|
|
@ -56,10 +65,10 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
|||
item.year?.let { Text(it.toString(), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) }
|
||||
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
||||
if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
||||
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings)
|
||||
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(factsItem.ratings)
|
||||
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body).lateLineFade(item.genres))
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(if (item.kind == "tv" || state.data.requestedStage != null) WolfskinTokens.Motion.readinessSlot else WolfskinTokens.Shell.zero).testTag("title-readiness-slot")) {
|
||||
Box(Modifier.fillMaxWidth().height(readinessHeight).clipToBounds().testTag("title-readiness-slot")) {
|
||||
state.data.requestedStage?.let { stage ->
|
||||
val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted }
|
||||
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap).lateLineFade(stage), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
|
|
|
|||
|
|
@ -120,7 +120,18 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
|||
val position by remember(list, rowOffsets) { derivedStateOf {
|
||||
rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset
|
||||
} }
|
||||
val headerPixels = with(density) { headerHeight.roundToPx() }
|
||||
val previousHeaderPixels = remember(list, item.id, density) { intArrayOf(headerPixels) }
|
||||
val compensateHeader = previousHeaderPixels[0] != headerPixels && !shell.navFocused && !list.isScrollInProgress &&
|
||||
list.firstVisibleItemIndex == 0 && list.firstVisibleItemScrollOffset > 0 &&
|
||||
list.layoutInfo.visibleItemsInfo.any { it.key == state.row && kotlin.math.abs(it.offset - with(density) { L.pinnedLine.roundToPx() }) <= 1 }
|
||||
// Compensate in the same remeasure; an asynchronous scroll would chase the animated header.
|
||||
androidx.compose.runtime.SideEffect {
|
||||
if (compensateHeader) list.requestScrollToItem(0, (list.firstVisibleItemScrollOffset + headerPixels - previousHeaderPixels[0]).coerceAtLeast(0))
|
||||
previousHeaderPixels[0] = headerPixels
|
||||
}
|
||||
LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows, rowOffsets) {
|
||||
if (compensateHeader) return@LaunchedEffect
|
||||
if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab)
|
||||
val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0)
|
||||
val y = rowOffsets[index]
|
||||
|
|
|
|||
|
|
@ -193,7 +193,6 @@ class TitleState(
|
|||
"menu" -> { sheetIndex = 0; sheet = TitleSheet.Menu }
|
||||
"watched" -> if (data.watchTarget.kind == "tv") { sheetIndex = 0; sheet = TitleSheet.ConfirmWatched } else toggleWatched()
|
||||
"trailer" -> data.trailerUrl?.let { onAction(TitleAction.Trailer(it)) }
|
||||
"seasons" -> focus("seasons", data.seasonNumbers.indexOf(season).coerceAtLeast(0))
|
||||
"retry" -> onAction(TitleAction.Retry)
|
||||
"back" -> onAction(TitleAction.Pop)
|
||||
"request", "add" -> onAction(TitleAction.Request(data.item, id != "request"))
|
||||
|
|
@ -302,7 +301,6 @@ fun titleOptions(state: TitleState): List<TitleOption> = buildList {
|
|||
add(TitleOption("watched", "Mark ${if (series) "series " else ""}as ${if (state.watched) "unwatched" else "watched"}", if (state.watched) "eye" else "eyeoff", if (data.watchTarget.kind == "episode") data.watchTarget.episodeLabel else "", on = state.watched))
|
||||
if (data.trailerUrl != null) add(TitleOption("trailer", "Play trailer", "trailer"))
|
||||
if (target != null || (data.connection == TitleConnection.SignedIn && data.requestsAllowed)) add(TitleOption("menu", "Menu", "more"))
|
||||
if (item.kind == "tv" && item.seasons > 0 && data.watchTarget.kind != "episode") add(TitleOption("seasons", "Seasons", "tv", icon = false))
|
||||
} else if (data.connection != TitleConnection.SignedIn || !data.requestsAllowed) {
|
||||
add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -44,20 +44,19 @@ class MotionSurfaceTest {
|
|||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(effectContext = animatorScale)
|
||||
private val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||
|
||||
@Test fun lateSeriesReadinessAndRatingsLeaveActionsOverviewAndEpisodesAtTheSameCoordinates() {
|
||||
@Test fun reducedMotionReadinessSnapsAndRemovingItRestoresTheCollapsedStack() {
|
||||
val shell = shell()
|
||||
val series = TitleItem("series", "A series", "tv", seasons = 2, held = true, overview = "Series overview")
|
||||
val episode = TitleItem("episode", "Pilot", "episode", season = 1, episode = 1, overview = "Episode overview")
|
||||
val state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {}
|
||||
rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } }
|
||||
rule.waitForIdle()
|
||||
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides true) { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } } }
|
||||
val before = bounds()
|
||||
for (stage in listOf("Ready", "Downloading", null)) {
|
||||
rule.runOnIdle { state.update(state.data.copy(requestedStage = stage, requestActor = "Mindel", item = series.copy(ratings = listOf(LibraryRating("rt", 88.0))))) }
|
||||
rule.mainClock.advanceTimeBy(3400)
|
||||
rule.waitForIdle()
|
||||
assertEquals("Late metadata cannot change reserved header geometry", before, bounds())
|
||||
}
|
||||
assertEquals(0f, rule.onNodeWithTag("title-readiness-slot").fetchSemanticsNode().boundsInRoot.height, 0.1f)
|
||||
rule.runOnIdle { state.update(state.data.copy(requestedStage = "Ready", requestActor = "Mindel")) }
|
||||
assertEquals(56f, rule.onNodeWithTag("title-readiness-slot").fetchSemanticsNode().boundsInRoot.height, 0.1f)
|
||||
bounds().zip(before).forEach { (after, initial) -> assertEquals(56f, after.top - initial.top, 1f) }
|
||||
rule.runOnIdle { state.update(state.data.copy(requestedStage = null)) }
|
||||
assertEquals(before, bounds())
|
||||
}
|
||||
|
||||
@Test fun titleSkeletonIgnoresDownAndBackPopsWithoutOpeningThePicker() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.MotionDurationScale
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
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
|
||||
import java.io.File
|
||||
|
||||
/** Readiness changes move real title content continuously, without reserving an empty band. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class TitleReadinessMotionTest {
|
||||
private val animatorScale = object : MotionDurationScale { override var scaleFactor by mutableFloatStateOf(1f) }
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(effectContext = animatorScale)
|
||||
private lateinit var state: TitleState
|
||||
|
||||
private fun render() {
|
||||
val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||
val shell = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/series") }
|
||||
val series = TitleItem("series", "A series", "tv", seasons = 1, held = true, overview = "A series overview")
|
||||
val episode = TitleItem("episode", "Pilot", "episode", held = true, season = 1, episode = 1, overview = "Episode overview")
|
||||
state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {}
|
||||
rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } }
|
||||
rule.mainClock.advanceTimeBy(500)
|
||||
rule.waitForIdle()
|
||||
}
|
||||
|
||||
private fun positions(): List<Float> = listOf("title-readiness-slot", "title-overview", "title-actions", "title-shelf:episodes").mapIndexed { index, tag ->
|
||||
val bounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot
|
||||
if (index == 0) bounds.height else bounds.top
|
||||
}
|
||||
|
||||
private fun frames(stage: String?, name: String, count: Int = 22): List<List<Float>> {
|
||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-readiness-motion/$name").apply { mkdirs() }
|
||||
rule.runOnIdle { state.update(state.data.copy(requestedStage = stage, requestActor = "Justin")); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||
val values = List(count) { index ->
|
||||
rule.waitForIdle()
|
||||
positions().also {
|
||||
rule.onRoot().captureRoboImage(File(directory, "${index.toString().padStart(2, '0')}.png").absolutePath)
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
rule.waitForIdle()
|
||||
}
|
||||
}
|
||||
File(directory, "samples.csv").writeText("timeMs,slotHeightPx,overviewTopPx,actionsTopPx,episodesTopPx\n" + values.mapIndexed { index, row -> "${index * 16},${row.joinToString(",")}" }.joinToString("\n"))
|
||||
return values
|
||||
}
|
||||
|
||||
@Test fun lateReadinessExpansionAndRemovalStayMonotonicAndMoveTheWholeStack() {
|
||||
render()
|
||||
val collapsed = positions()
|
||||
assertEquals(0f, collapsed[0], 0.1f)
|
||||
rule.mainClock.autoAdvance = false
|
||||
val opening = frames("Ready", "expand")
|
||||
val expanded = opening.last()
|
||||
assertEquals(56f, expanded[0], 0.1f)
|
||||
expanded.drop(1).zip(collapsed.drop(1)).forEach { (after, before) -> assertEquals(56f, after - before, 1f) }
|
||||
val closing = frames(null, "collapse")
|
||||
assertEquals(collapsed, closing.last())
|
||||
for ((samples, direction) in listOf(opening to 1f, closing to -1f)) {
|
||||
samples.zipWithNext().forEach { (before, after) -> before.zip(after).forEach { (first, second) ->
|
||||
assertTrue("Readiness content must not reverse or overshoot: $samples", (second - first) * direction >= -0.1f)
|
||||
assertTrue("Height change must not jump in one frame", kotlin.math.abs(second - first) < 56f)
|
||||
} }
|
||||
samples.forEach { assertTrue(it[0] in 0f..56f) }
|
||||
}
|
||||
assertFalse(state.options.any { it.id == "seasons" })
|
||||
rule.mainClock.autoAdvance = true
|
||||
}
|
||||
|
||||
@Test fun aPinnedEpisodeShelfDoesNotBounceWhenReadinessChangesAboveIt() {
|
||||
render()
|
||||
rule.runOnIdle { state.focus("episodes", 0) }
|
||||
rule.mainClock.advanceTimeBy(500)
|
||||
rule.waitForIdle()
|
||||
val pinned = positions().last()
|
||||
rule.mainClock.autoAdvance = false
|
||||
for (samples in listOf(frames("Ready", "pinned", count = 34), frames(null, "pinned-collapse", count = 34))) {
|
||||
samples.forEach { assertEquals("A header change cannot bounce a pinned focused shelf", pinned, it.last(), 1f) }
|
||||
}
|
||||
assertEquals("episodes", state.row)
|
||||
rule.mainClock.autoAdvance = true
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue