Round 2 release candidate 3: polish lanes A-E, season reel and trailer return #82
5 changed files with 466 additions and 39 deletions
|
|
@ -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.AnimationVector1D
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.LazyListLayoutInfo
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
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.setValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -16,6 +26,13 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.layout.layout
|
||||
import kotlin.math.roundToInt
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
|
|
@ -29,6 +46,7 @@ import androidx.tv.material3.Text
|
|||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
|
||||
|
|
@ -36,10 +54,19 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
|
|||
/** The overlay intercepts pointer input as well as the shell's virtual D-pad focus. */
|
||||
@Composable
|
||||
fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
|
||||
val visible = shell.requestsVisible
|
||||
val fade = rememberSheetFade(visible)
|
||||
if (!visible && fade.value == 0f) return
|
||||
val state = shell.requests
|
||||
val data = state.snapshot
|
||||
val current = RequestsPanelFrame(state.snapshot, state.focusedId, state.expanded, state.windowStart, state.windowSize,
|
||||
state.recoveryFocused, state.ratingsVisible, shell.requestsExpansionInput)
|
||||
val retained = remember { mutableStateOf(current) }
|
||||
SideEffect { if (visible) retained.value = current }
|
||||
val frame = if (visible) current else retained.value
|
||||
val data = frame.data
|
||||
val ratingsReader = LocalScreenRatings.current
|
||||
LaunchedEffect(state, state.focusedId, data.signedIn, data.status) {
|
||||
LaunchedEffect(visible, state, state.focusedId, data.signedIn, data.status) {
|
||||
if (!visible) return@LaunchedEffect
|
||||
if (ratingsReader == null) state.awaitFocusedRatings()
|
||||
else if (data.signedIn) state.focused?.let { item ->
|
||||
ratingsReader.read(item.tmdbId, item.mediaType, null, null, cachedOnly = data.status == ComingInStatus.Unavailable)?.let { values ->
|
||||
|
|
@ -50,8 +77,9 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
|
|||
}
|
||||
}
|
||||
}
|
||||
Box(modifier.fillMaxSize().background(C.sheetScrim).clickable { shell.closeRequests() }.testTag("coming-in-scrim"))
|
||||
Column(modifier.fillMaxHeight().width(L.sheetWidth)
|
||||
Box(modifier.fillMaxSize().sheetScrim { fade.value }.focusProperties { canFocus = false }
|
||||
.clickable { if (visible) shell.closeRequests() }.testTag("coming-in-scrim"))
|
||||
Column(modifier.sheetPanelFade { fade.value }.fillMaxHeight().width(L.sheetWidth)
|
||||
.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth)
|
||||
.drawWithCache {
|
||||
val panel = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||
|
|
@ -61,9 +89,11 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
|
|||
onDrawBehind {
|
||||
drawIntoCanvas { it.nativeCanvas.drawRect(0f, 0f, size.width, size.height, panel) }
|
||||
}
|
||||
}.pointerInput(Unit) {
|
||||
}.focusProperties { canFocus = visible }
|
||||
.then(if (visible) Modifier else Modifier.clearAndSetSemantics { })
|
||||
.pointerInput(visible) {
|
||||
awaitPointerEventScope {
|
||||
while (true) awaitPointerEvent(PointerEventPass.Final).changes.forEach { it.consume() }
|
||||
while (true) awaitPointerEvent(if (visible) PointerEventPass.Final else PointerEventPass.Initial).changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
.padding(horizontal = T.sheetPaddingX, vertical = T.sheetPaddingY)
|
||||
|
|
@ -71,10 +101,10 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
|
|||
Text("Coming in", Modifier.comingInLineBox(T.sheetTitleLineHeight, T.sheetTitleBaseline), style = ComingInTypography.title, color = C.fg)
|
||||
when {
|
||||
data.status == ComingInStatus.Loading && data.items.isEmpty() -> {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(T.listGap)) {
|
||||
LazyColumn(Modifier.skeletonPulse(), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
|
||||
items(3, key = { "request-skeleton-$it" }, contentType = { "skeleton" }) { ComingInSkeleton() }
|
||||
}
|
||||
SheetButton("Close") { shell.closeRequests() }
|
||||
SheetButton("Close", interactive = visible) { shell.closeRequests() }
|
||||
}
|
||||
data.status == ComingInStatus.Unavailable && data.items.isEmpty() -> {
|
||||
Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
|
|
@ -86,34 +116,50 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
|
|||
}
|
||||
Text("Conductarr didn’t answer.", style = ComingInTypography.body, color = C.muted)
|
||||
}
|
||||
SheetButton("Try again", primary = true) { shell.retryRequests() }
|
||||
SheetButton("Try again", primary = true, interactive = visible) { shell.retryRequests() }
|
||||
}
|
||||
data.items.isEmpty() -> {
|
||||
Column(Modifier.padding(top = T.emptyMargin), verticalArrangement = Arrangement.spacedBy(T.receiptGap)) {
|
||||
Text("Nothing coming in.", style = ComingInTypography.body, color = C.muted)
|
||||
Text("New requests show up here.", style = ComingInTypography.body, color = C.muted)
|
||||
}
|
||||
SheetButton("Close", interactive = visible) { shell.closeRequests() }
|
||||
}
|
||||
data.items.isEmpty() -> Text("Nothing coming in.", Modifier.padding(top = T.emptyMargin), style = ComingInTypography.body, color = C.muted)
|
||||
else -> {
|
||||
if (data.status == ComingInStatus.Unavailable) Text("Conductarr didn’t answer.", style = ComingInTypography.caption, color = C.warn)
|
||||
data.savedAt?.takeIf { data.status == ComingInStatus.Unavailable }?.let { SavedPill(it) }
|
||||
if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn)
|
||||
val start = state.windowStart
|
||||
val visibleRows = remember(data, start, state.windowSize) {
|
||||
data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size))
|
||||
val start = frame.start
|
||||
val listState = rememberLazyListState()
|
||||
val placement = rememberRequestPlacement(frame, listState)
|
||||
val visibleRows = remember(data, start, frame.windowSize) {
|
||||
data.ordered.subList(start, (start + frame.windowSize).coerceAtMost(data.ordered.size))
|
||||
}
|
||||
LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
|
||||
LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier),
|
||||
state = listState, verticalArrangement = Arrangement.spacedBy(T.listGap)) {
|
||||
val keys = visibleRows.map { it.id }.uniqueKeys()
|
||||
itemsIndexed(visibleRows, key = { index, _ -> keys[index] }, contentType = { _, _ -> "request" }) { _, item ->
|
||||
ComingInRow(item, shell.person, !state.recoveryFocused && item.id == state.focusedId, state.expanded && item.id == state.focusedId,
|
||||
state.ratingsVisible && data.signedIn && item.id == state.focusedId,
|
||||
onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() })
|
||||
itemsIndexed(visibleRows, key = { index, _ -> keys[index] }, contentType = { _, _ -> "request" }) { index, item ->
|
||||
ComingInRow(item, shell.person, !frame.recoveryFocused && item.id == frame.focusedId, frame.expanded && item.id == frame.focusedId,
|
||||
frame.ratingsVisible && data.signedIn && item.id == frame.focusedId,
|
||||
onFocus = { if (visible) state.focus(item.id) }, onClick = { if (visible) { state.focus(item.id); shell.activateRequest() } },
|
||||
modifier = Modifier.layout { measurable, constraints ->
|
||||
val row = measurable.measure(constraints)
|
||||
layout(row.width, row.height) {
|
||||
row.placeRelative(0, placement.offset(keys[index], listState.layoutInfo))
|
||||
}
|
||||
},
|
||||
readinessProgress = { placement.progress.value }, interactive = visible)
|
||||
}
|
||||
}
|
||||
if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = state.recoveryFocused) { shell.retryRequests() }
|
||||
if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = frame.recoveryFocused, interactive = visible) { shell.retryRequests() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, onClick: () -> Unit) {
|
||||
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = onClick) }
|
||||
private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, interactive: Boolean = true, onClick: () -> Unit) {
|
||||
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = { if (interactive) onClick() }) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -128,32 +174,95 @@ private fun ComingInSkeleton() {
|
|||
}
|
||||
}
|
||||
|
||||
private data class RequestsPanelFrame(val data: ComingInSnapshot, val focusedId: String?, val expanded: Boolean,
|
||||
val start: Int, val windowSize: Int, val recoveryFocused: Boolean, val ratingsVisible: Boolean, val expansionInput: Long)
|
||||
|
||||
private class RequestPlacementMemory(var frame: RequestsPanelFrame, var motion: RequestsPlacement? = null)
|
||||
|
||||
/** Placement reads one clock; target-offset caching happens only when the list is measured. */
|
||||
private class RequestsPlacement(val animate: Boolean, private val origins: Map<Any, Int>) {
|
||||
val progress: Animatable<Float, AnimationVector1D> = Animatable(if (animate) 0f else 1f)
|
||||
private var measured: LazyListLayoutInfo? = null
|
||||
private var targets: Map<Any, Int> = emptyMap()
|
||||
|
||||
fun offset(key: Any, info: LazyListLayoutInfo): Int {
|
||||
val progress = progress.value
|
||||
if (!animate || progress == 1f) return 0
|
||||
if (measured !== info) {
|
||||
targets = info.visibleItemsInfo.associate { it.key to it.offset }
|
||||
measured = info
|
||||
}
|
||||
val target = targets[key] ?: return 0
|
||||
return (((origins[key] ?: target) - target) * (1f - progress)).roundToInt()
|
||||
}
|
||||
}
|
||||
|
||||
/** Only remote expansion toggles animate; interruptions start at the current visual placement. */
|
||||
@Composable
|
||||
private fun rememberRequestPlacement(frame: RequestsPanelFrame, listState: LazyListState): RequestsPlacement {
|
||||
val previous = remember { RequestPlacementMemory(frame) }
|
||||
val reduced = reducedMotion()
|
||||
val motion = remember(frame, reduced) {
|
||||
val animate = !reduced && previous.frame.let {
|
||||
it.expansionInput != frame.expansionInput && it.data === frame.data && it.focusedId == frame.focusedId &&
|
||||
it.start == frame.start && it.expanded != frame.expanded
|
||||
}
|
||||
val info = listState.layoutInfo
|
||||
val origins = if (animate) info.visibleItemsInfo.associate {
|
||||
it.key to it.offset + (previous.motion?.offset(it.key, info) ?: 0)
|
||||
} else emptyMap()
|
||||
RequestsPlacement(animate, origins)
|
||||
}
|
||||
SideEffect { previous.frame = frame; previous.motion = motion }
|
||||
LaunchedEffect(motion) {
|
||||
if (motion.animate) motion.progress.animateTo(1f, tween(M.lateLineFade, easing = M.easeOut))
|
||||
}
|
||||
return motion
|
||||
}
|
||||
|
||||
/** Thumbnail slots do not resize when the text or the readiness block grows. */
|
||||
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, expanded: Boolean,
|
||||
showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit) {
|
||||
showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit, modifier: Modifier = Modifier,
|
||||
readinessProgress: () -> Float = { 1f }, interactive: Boolean = true) {
|
||||
val foreground = if (focused) C.onFocus else C.fg
|
||||
val secondary = if (focused) C.onFocus else C.muted
|
||||
val context = LocalContext.current
|
||||
val request = remember(item.poster, context) {
|
||||
item.poster?.let { WolfskinImages.request(context, it, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt(), true) }
|
||||
}
|
||||
val posterReady = remember(request) { mutableStateOf(false) }
|
||||
Row(Modifier.fillMaxWidth().focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
||||
val posterState = remember(request) { mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty) }
|
||||
var collapsedHeight by remember { mutableIntStateOf(0) }
|
||||
Row(modifier.fillMaxWidth().testTag("coming-row-${item.id}")
|
||||
.onSizeChanged { if (!expanded) collapsedHeight = it.height }
|
||||
.drawWithContent {
|
||||
val progress = readinessProgress()
|
||||
if (expanded && collapsedHeight > 0 && progress < 1f) {
|
||||
val bottom = collapsedHeight + (size.height - collapsedHeight) * progress
|
||||
clipRect(left = -size.width, top = -size.height, right = size.width * 2, bottom = bottom) {
|
||||
this@drawWithContent.drawContent()
|
||||
}
|
||||
} else drawContent()
|
||||
}.focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
||||
.background(if (focused) C.fg else Color.Transparent, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
.pointerInput(item.id) {
|
||||
.then(if (interactive) Modifier.pointerInput(item.id) {
|
||||
awaitPointerEventScope {
|
||||
while (true) if (awaitPointerEvent().type == PointerEventType.Enter) onFocus()
|
||||
}
|
||||
}.clickable(onClick = onClick)
|
||||
.padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY).testTag("coming-row-${item.id}"),
|
||||
} else Modifier).clickable(enabled = interactive, onClick = onClick)
|
||||
.padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY),
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) {
|
||||
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null,
|
||||
modifier = Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb))
|
||||
.background(if (posterReady.value) Color.Transparent else C.surface3),
|
||||
onState = { posterReady.value = it is AsyncImagePainter.State.Success },
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop)
|
||||
Box(Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb))
|
||||
.background(if (posterState.value is AsyncImagePainter.State.Success) Color.Transparent else C.surface3)
|
||||
.testTag("coming-thumb-${item.id}"), contentAlignment = Alignment.Center) {
|
||||
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(), onState = { posterState.value = it },
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop)
|
||||
if (request == null || posterState.value is AsyncImagePainter.State.Error) {
|
||||
Glyph(if (item.mediaType == "movie") "film" else "tv", C.dim, Modifier.size(T.thumbGlyph))
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline),
|
||||
style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ class ShellState(
|
|||
var peopleStatus by mutableStateOf(PeopleStatus.Ready)
|
||||
var recoveryIndex by mutableStateOf(0)
|
||||
var requestsVisible by mutableStateOf(false)
|
||||
internal var requestsExpansionInput = 0L
|
||||
private set
|
||||
var toast by mutableStateOf<String?>(null)
|
||||
private val personRequests = mutableMapOf<String, ComingInState>()
|
||||
val requests: ComingInState get() = personRequests.getValue(person.key)
|
||||
|
|
@ -161,8 +163,12 @@ class ShellState(
|
|||
when {
|
||||
key == ShellKey.Back -> closeRequests()
|
||||
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Unavailable && (requests.snapshot.items.isEmpty() || requests.recoveryFocused) -> retryRequests()
|
||||
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Loading && requests.snapshot.items.isEmpty() -> closeRequests()
|
||||
requests.key(key) -> activateRequest()
|
||||
key == ShellKey.Ok && requests.snapshot.items.isEmpty() && requests.snapshot.status != ComingInStatus.Unavailable -> closeRequests()
|
||||
else -> {
|
||||
val expanded = requests.expanded
|
||||
if (requests.key(key)) activateRequest()
|
||||
if ((key == ShellKey.Right || key == ShellKey.Left) && requests.expanded != expanded) requestsExpansionInput++
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,12 +96,14 @@ fun ShellSurface(
|
|||
if (state.pickerVisible) {
|
||||
WhosWatching(state)
|
||||
} else {
|
||||
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
||||
FocusRingHost {
|
||||
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
||||
}
|
||||
NavBar(state, view.listState.firstVisibleItemIndex > 0 || view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
|
||||
if (state.requestsVisible) ComingInSheet(state)
|
||||
ComingInSheet(state)
|
||||
overlay()
|
||||
}
|
||||
state.toast?.let { RecoveryToast(it) }
|
||||
RecoveryToastHost(state.toast)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -100,9 +100,9 @@ class RequestsKeysTest {
|
|||
assertEquals(1, retries)
|
||||
assertTrue(state.requestsVisible)
|
||||
rule.runOnIdle { state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready)) }
|
||||
rule.onNodeWithText("New requests show up here.").assertExists()
|
||||
send(Key.DirectionDown, Key.DirectionCenter)
|
||||
rule.onNodeWithText("Nothing coming in.").assertExists()
|
||||
assertTrue(state.requestsVisible)
|
||||
assertFalse(state.requestsVisible)
|
||||
}
|
||||
|
||||
@Test fun cachedRatingsRenderInTheTelevisionLocale() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,310 @@
|
|||
// 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.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import coil3.intercept.Interceptor
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
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
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
||||
|
||||
/** Native frames exercise Requests transitions without replacing the shell's key or focus policy. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class RequestsPolishTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private val reduced = mutableStateOf(false)
|
||||
|
||||
@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
|
||||
rule.mainClock.autoAdvance = false
|
||||
}
|
||||
|
||||
private fun shell(snapshot: ComingInSnapshot = requestsFixture()) = ShellState(fixturePeople(), "justin").apply {
|
||||
pickerVisible = false
|
||||
pickerLaunch = false
|
||||
enterDestination("settings")
|
||||
settingsRow = 2
|
||||
requests.update(snapshot)
|
||||
}
|
||||
|
||||
private fun mount(state: ShellState) {
|
||||
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) {
|
||||
WolfskinTheme { ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) }
|
||||
} }
|
||||
rule.mainClock.advanceTimeBy(32)
|
||||
rule.waitForIdle()
|
||||
}
|
||||
|
||||
private fun change(block: () -> Unit) {
|
||||
rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
rule.waitForIdle()
|
||||
}
|
||||
|
||||
private fun capture(idea: String, name: String): Bitmap {
|
||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/$idea").apply { mkdirs() }
|
||||
val file = File(directory, "$name.png")
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
return BitmapFactory.decodeFile(file.absolutePath)
|
||||
}
|
||||
|
||||
private fun csv(idea: String, rows: List<String>) {
|
||||
File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/$idea/samples.csv").writeText(rows.joinToString("\n", postfix = "\n"))
|
||||
}
|
||||
|
||||
private fun brightness(pixel: Int) = android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
|
||||
private fun monotonic(values: List<Float>, increasing: Boolean) {
|
||||
assertNotEquals("Transition must change the sampled value", values.first(), values.last())
|
||||
values.zipWithNext().forEach { (first, second) -> assertTrue("Non-monotonic samples: $values", if (increasing) second >= first - 1f else second <= first + 1f) }
|
||||
assertTrue("Transition must include an intermediate frame: $values", values.any { it > minOf(values.first(), values.last()) + 1 && it < maxOf(values.first(), values.last()) - 1 })
|
||||
}
|
||||
|
||||
@Test fun requestsPanelFadesWithItsScrim() {
|
||||
val state = shell(ComingInSnapshot(status = ComingInStatus.Ready))
|
||||
mount(state)
|
||||
val samples = mutableListOf("mode,phase,timeMs,scrim,panel")
|
||||
for (reduce in listOf(false, true)) {
|
||||
change { reduced.value = reduce; state.openRequests() }
|
||||
val entering = List(13) { index ->
|
||||
val image = capture("P06", "requests-${if (reduce) "reduced-" else ""}open-${index.toString().padStart(2, '0')}")
|
||||
val pixels = brightness(image.getPixel(400, 900)).toFloat() to brightness(image.getPixel(1500, 900)).toFloat()
|
||||
image.recycle()
|
||||
samples += "$reduce,open,${index * 16},${pixels.first},${pixels.second}"
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
pixels
|
||||
}
|
||||
val row = state.settingsRow
|
||||
val nav = state.navFocused
|
||||
change { state.closeRequests() }
|
||||
assertEquals(row, state.settingsRow)
|
||||
assertEquals(nav, state.navFocused)
|
||||
rule.onNodeWithTag("coming-in-scrim").performTouchInput { click(Offset(400f, 900f)) }
|
||||
val leaving = List(9) { index ->
|
||||
val image = capture("P06", "requests-${if (reduce) "reduced-" else ""}close-${index.toString().padStart(2, '0')}")
|
||||
val pixels = brightness(image.getPixel(400, 900)).toFloat() to brightness(image.getPixel(1500, 900)).toFloat()
|
||||
image.recycle()
|
||||
samples += "$reduce,close,${index * 16},${pixels.first},${pixels.second}"
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
pixels
|
||||
}
|
||||
csv("P06", samples)
|
||||
monotonic(entering.map { it.first }, false)
|
||||
monotonic(entering.map { it.second }, true)
|
||||
monotonic(leaving.map { it.first }, true)
|
||||
monotonic(leaving.map { it.second }, false)
|
||||
assertFalse(state.requestsVisible)
|
||||
assertEquals(row, state.settingsRow)
|
||||
assertEquals(nav, state.navFocused)
|
||||
rule.onNodeWithTag("coming-in").assertDoesNotExist()
|
||||
val end = if (reduce) 5 else 10
|
||||
assertEquals(entering.last(), entering[end + 1])
|
||||
assertEquals(leaving.last(), leaving[if (reduce) 6 else 8])
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun loadingRowsBreatheAsOneGroupAndStopWhenLoaded() {
|
||||
val state = shell(ComingInSnapshot(status = ComingInStatus.Loading)).apply { openRequests() }
|
||||
mount(state)
|
||||
rule.mainClock.advanceTimeBy(240)
|
||||
val samples = mutableListOf("mode,timeMs,thumb1,thumb2,button")
|
||||
for (reduce in listOf(false, true)) {
|
||||
change { reduced.value = reduce }
|
||||
val close = rule.onNodeWithText("Close").fetchSemanticsNode().boundsInRoot
|
||||
val times = ((0..1400 step 100).toList() + 350).sorted()
|
||||
val values = times.mapIndexed { index, time ->
|
||||
val image = capture("P07", "requests-loading-${if (reduce) "reduced-" else ""}${time.toString().padStart(4, '0')}")
|
||||
val pixels = listOf(image.getPixel(1240, 240), image.getPixel(1240, 418), image.getPixel(close.center.x.toInt(), close.bottom.toInt() + 8))
|
||||
image.recycle()
|
||||
samples += "$reduce,$time,${pixels.joinToString(",")}"
|
||||
if (index < times.lastIndex) rule.mainClock.advanceTimeBy((times[index + 1] - time).toLong(), ignoreFrameDuration = true)
|
||||
pixels
|
||||
}
|
||||
csv("P07", samples)
|
||||
assertEquals(values.map { it[0] }, values.map { it[1] })
|
||||
assertEquals(1, values.map { it[2] }.distinct().size)
|
||||
if (reduce) assertEquals(1, values.map { it[0] }.distinct().size)
|
||||
else {
|
||||
assertNotEquals(values[0][0], values[4][0])
|
||||
assertNotEquals(values[4][0], values[8][0])
|
||||
}
|
||||
}
|
||||
change { reduced.value = false; state.requests.update(requestsFixture()) }
|
||||
rule.mainClock.advanceTimeBy(400)
|
||||
rule.onNodeWithText("Close").assertDoesNotExist()
|
||||
assertTrue("Loaded panel must leave the recomposer idle", androidx.compose.runtime.Recomposer.runningRecomposers.value.all { !it.hasPendingWork })
|
||||
}
|
||||
|
||||
@Test fun readinessExpansionPushesRowsBelowWithoutRemeasure() {
|
||||
val state = shell().apply { openRequests(); requests.key(ShellKey.Down) }
|
||||
mount(state)
|
||||
// Rating dwell changes layout independently; toggles require settled metadata.
|
||||
rule.mainClock.advanceTimeBy(M.ratingDwell.toLong())
|
||||
val samples = mutableListOf("mode,phase,timeMs,focusedTop,followingTop")
|
||||
for (reduce in listOf(false, true)) {
|
||||
change { reduced.value = reduce }
|
||||
val focusedBounds = rule.onNodeWithTag("coming-row-x1").fetchSemanticsNode().boundsInRoot
|
||||
fun focusedTop(): Float {
|
||||
val bounds = rule.onNodeWithTag("coming-row-x1").fetchSemanticsNode().boundsInRoot
|
||||
val scale = WolfskinTokens.Focus.scaleRow
|
||||
val scaleOffset = kotlin.math.abs(bounds.height - focusedBounds.height) * (scale - 1f) / (2f * scale)
|
||||
assertEquals(focusedBounds.top, bounds.top, scaleOffset + 0.5f)
|
||||
return bounds.top
|
||||
}
|
||||
val collapsed = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top
|
||||
change { state.key(ShellKey.Right) }
|
||||
val expanding = List(16) { index ->
|
||||
val top = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top
|
||||
val fixed = focusedTop()
|
||||
capture("P09", "expand-${if (reduce) "reduced-" else ""}${index.toString().padStart(2, '0')}").recycle()
|
||||
samples += "$reduce,expand,${index * 16},$fixed,$top"
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
top
|
||||
}
|
||||
change { state.key(ShellKey.Left) }
|
||||
val collapsing = List(16) { index ->
|
||||
val top = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top
|
||||
capture("P09", "collapse-${if (reduce) "reduced-" else ""}${index.toString().padStart(2, '0')}").recycle()
|
||||
samples += "$reduce,collapse,${index * 16},${focusedTop()},$top"
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
top
|
||||
}
|
||||
csv("P09", samples)
|
||||
assertEquals(collapsed, collapsing.last(), 0.1f)
|
||||
if (reduce) {
|
||||
assertTrue(expanding.all { kotlin.math.abs(it - expanding.last()) < 0.1f })
|
||||
assertTrue(collapsing.all { kotlin.math.abs(it - collapsed) < 0.1f })
|
||||
} else {
|
||||
monotonic(listOf(collapsed) + expanding, true)
|
||||
assertTrue("Expansion must not finish early", expanding[5] < expanding.last() - 1f)
|
||||
monotonic(listOf(expanding.last()) + collapsing, false)
|
||||
assertTrue("Collapse must not finish early", collapsing[5] > collapsing.last() + 1f)
|
||||
}
|
||||
}
|
||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/P09")
|
||||
for (phase in listOf("expand", "collapse")) {
|
||||
val animated = BitmapFactory.decodeFile(File(directory, "$phase-15.png").absolutePath)
|
||||
val snapped = BitmapFactory.decodeFile(File(directory, "$phase-reduced-15.png").absolutePath)
|
||||
try { assertTrue("Settled $phase pixels must match reduced-motion snapping", animated.sameAs(snapped)) }
|
||||
finally { animated.recycle(); snapped.recycle() }
|
||||
}
|
||||
val collapsedTop = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top
|
||||
change { reduced.value = false; state.key(ShellKey.Right) }
|
||||
rule.mainClock.advanceTimeBy(48)
|
||||
change { state.key(ShellKey.Left) }
|
||||
val retargeted = List(16) { index ->
|
||||
val top = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top
|
||||
capture("P09", "retarget-${index.toString().padStart(2, '0')}").recycle()
|
||||
samples += "false,retarget,${index * 16},${rule.onNodeWithTag("coming-row-x1").fetchSemanticsNode().boundsInRoot.top},$top"
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
top
|
||||
}
|
||||
assertTrue("An interrupted expand must return from its current placement", retargeted.first() > collapsedTop + 1f)
|
||||
monotonic(retargeted, false)
|
||||
assertEquals(collapsedTop, retargeted.last(), 0.1f)
|
||||
change { reduced.value = false; state.key(ShellKey.Right) }
|
||||
rule.mainClock.advanceTimeBy(48)
|
||||
change { state.key(ShellKey.Down) }
|
||||
val jumped = rule.onNodeWithTag("coming-row-x3").fetchSemanticsNode().boundsInRoot.top
|
||||
rule.mainClock.advanceTimeBy(240)
|
||||
assertEquals(jumped, rule.onNodeWithTag("coming-row-x3").fetchSemanticsNode().boundsInRoot.top, 0.1f)
|
||||
rule.mainClock.advanceTimeBy(M.ratingDwell.toLong())
|
||||
change { state.key(ShellKey.Right) }
|
||||
rule.mainClock.advanceTimeBy(48)
|
||||
change { state.requests.update(state.requests.snapshot.copy(partial = true)) }
|
||||
val refreshed = rule.onNodeWithTag("coming-row-x3").fetchSemanticsNode().boundsInRoot.top
|
||||
rule.mainClock.advanceTimeBy(240)
|
||||
assertEquals(refreshed, rule.onNodeWithTag("coming-row-x3").fetchSemanticsNode().boundsInRoot.top, 0.1f)
|
||||
csv("P09", samples)
|
||||
}
|
||||
|
||||
@Test fun emptyRequestsCloseReturnsToSettingsRow() {
|
||||
val state = shell(ComingInSnapshot(status = ComingInStatus.Ready)).apply { openRequests() }
|
||||
mount(state)
|
||||
rule.mainClock.advanceTimeBy(240)
|
||||
capture("P10", "requests-empty").recycle()
|
||||
rule.onNodeWithText("New requests show up here.").assertExists()
|
||||
val row = state.settingsRow
|
||||
val nav = state.navFocused
|
||||
change { state.key(ShellKey.Down); state.key(ShellKey.Ok) }
|
||||
assertFalse(state.requestsVisible)
|
||||
assertEquals(row, state.settingsRow)
|
||||
assertEquals(nav, state.navFocused)
|
||||
rule.mainClock.advanceTimeBy(160)
|
||||
change { state.openRequests() }
|
||||
rule.mainClock.advanceTimeBy(240)
|
||||
rule.onNodeWithText("Close").performClick()
|
||||
assertFalse(state.requestsVisible)
|
||||
assertEquals(row, state.settingsRow)
|
||||
assertEquals(nav, state.navFocused)
|
||||
}
|
||||
|
||||
@Test fun missingPosterShowsFilmOrTvGlyph() {
|
||||
val completed = AtomicBoolean(false)
|
||||
WolfskinImages.configureNetwork(rule.activity) {
|
||||
add(Interceptor { chain ->
|
||||
if (chain.request.data == "pending-poster") awaitCancellation()
|
||||
else chain.proceed().also { if (chain.request.data == "file:///missing-wolfskin-poster.png") completed.set(true) }
|
||||
})
|
||||
}
|
||||
val source = requestsFixture().items.first()
|
||||
val items = listOf(source.copy(id = "movie", mediaType = "movie", poster = null),
|
||||
source.copy(id = "tv", mediaType = "tv", poster = null),
|
||||
source.copy(id = "failed", mediaType = "movie", poster = "file:///missing-wolfskin-poster.png"),
|
||||
source.copy(id = "pending", poster = "pending-poster"))
|
||||
val state = shell(ComingInSnapshot(items, ComingInStatus.Ready)).apply { openRequests(); requests.focus("pending") }
|
||||
mount(state)
|
||||
rule.waitUntil(5000) { completed.get() }
|
||||
rule.mainClock.advanceTimeBy(400)
|
||||
val image = capture("P12", "missing-thumbs")
|
||||
try {
|
||||
fun glyph(id: String): List<Int> {
|
||||
val thumb = rule.onNodeWithTag("coming-thumb-$id", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||
val left = (thumb.center.x - 20).toInt()
|
||||
val top = (thumb.center.y - 20).toInt()
|
||||
return (0 until 40).flatMap { y -> (0 until 40).map { x -> image.getPixel(left + x, top + y) } }
|
||||
}
|
||||
val movie = glyph("movie")
|
||||
val tv = glyph("tv")
|
||||
val failed = glyph("failed")
|
||||
val stroke = C.dim.compositeOver(C.surface3).toArgb()
|
||||
listOf(movie, tv, failed).forEach { pixels ->
|
||||
assertTrue("The thumbnail centre column must contain the dim glyph stroke", (0 until 40).any { y -> pixels[y * 40 + 20] == stroke })
|
||||
}
|
||||
assertNotEquals(movie, tv)
|
||||
val deltas = movie.zip(failed).map { (first, second) ->
|
||||
listOf(16, 8, 0).map { shift -> kotlin.math.abs(((first ushr shift) and 255) - ((second ushr shift) and 255)) }
|
||||
}
|
||||
assertTrue("Absent and failed posters must render the same film glyph", deltas.flatten().average() < 2)
|
||||
assertTrue("Only antialiasing differences are allowed", deltas.count { it.max() <= 8 } >= movie.size * 0.99)
|
||||
assertTrue("Loading remains a plain thumbnail", glyph("pending").all { it == C.surface3.toArgb() })
|
||||
} finally { image.recycle(); WolfskinImages.configureNetwork(rule.activity) {} }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue