Polish Requests transitions and shell hosts
This commit is contained in:
parent
74b737fb52
commit
2572da00fd
5 changed files with 466 additions and 39 deletions
|
|
@ -1,14 +1,24 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
package org.justinvisser.wolfskin.design
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.draw.drawWithCache
|
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.drawscope.drawIntoCanvas
|
||||||
import androidx.compose.ui.graphics.nativeCanvas
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
|
@ -29,6 +46,7 @@ import androidx.tv.material3.Text
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import coil3.compose.AsyncImagePainter
|
import coil3.compose.AsyncImagePainter
|
||||||
import androidx.compose.runtime.mutableStateOf
|
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.Color as C
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
|
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. */
|
/** The overlay intercepts pointer input as well as the shell's virtual D-pad focus. */
|
||||||
@Composable
|
@Composable
|
||||||
fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
|
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 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
|
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()
|
if (ratingsReader == null) state.awaitFocusedRatings()
|
||||||
else if (data.signedIn) state.focused?.let { item ->
|
else if (data.signedIn) state.focused?.let { item ->
|
||||||
ratingsReader.read(item.tmdbId, item.mediaType, null, null, cachedOnly = data.status == ComingInStatus.Unavailable)?.let { values ->
|
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"))
|
Box(modifier.fillMaxSize().sheetScrim { fade.value }.focusProperties { canFocus = false }
|
||||||
Column(modifier.fillMaxHeight().width(L.sheetWidth)
|
.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)
|
.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth)
|
||||||
.drawWithCache {
|
.drawWithCache {
|
||||||
val panel = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
val panel = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
|
@ -61,9 +89,11 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
|
||||||
onDrawBehind {
|
onDrawBehind {
|
||||||
drawIntoCanvas { it.nativeCanvas.drawRect(0f, 0f, size.width, size.height, panel) }
|
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 {
|
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)
|
.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)
|
Text("Coming in", Modifier.comingInLineBox(T.sheetTitleLineHeight, T.sheetTitleBaseline), style = ComingInTypography.title, color = C.fg)
|
||||||
when {
|
when {
|
||||||
data.status == ComingInStatus.Loading && data.items.isEmpty() -> {
|
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() }
|
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() -> {
|
data.status == ComingInStatus.Unavailable && data.items.isEmpty() -> {
|
||||||
Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
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)
|
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 -> {
|
else -> {
|
||||||
if (data.status == ComingInStatus.Unavailable) Text("Conductarr didn’t answer.", style = ComingInTypography.caption, color = C.warn)
|
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) }
|
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)
|
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 start = frame.start
|
||||||
val visibleRows = remember(data, start, state.windowSize) {
|
val listState = rememberLazyListState()
|
||||||
data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size))
|
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()
|
val keys = visibleRows.map { it.id }.uniqueKeys()
|
||||||
itemsIndexed(visibleRows, key = { index, _ -> keys[index] }, contentType = { _, _ -> "request" }) { _, item ->
|
itemsIndexed(visibleRows, key = { index, _ -> keys[index] }, contentType = { _, _ -> "request" }) { index, item ->
|
||||||
ComingInRow(item, shell.person, !state.recoveryFocused && item.id == state.focusedId, state.expanded && item.id == state.focusedId,
|
ComingInRow(item, shell.person, !frame.recoveryFocused && item.id == frame.focusedId, frame.expanded && item.id == frame.focusedId,
|
||||||
state.ratingsVisible && data.signedIn && item.id == state.focusedId,
|
frame.ratingsVisible && data.signedIn && item.id == frame.focusedId,
|
||||||
onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() })
|
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
|
@Composable
|
||||||
private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, onClick: () -> Unit) {
|
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 = onClick) }
|
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = { if (interactive) onClick() }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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. */
|
/** Thumbnail slots do not resize when the text or the readiness block grows. */
|
||||||
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class)
|
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, expanded: Boolean,
|
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 foreground = if (focused) C.onFocus else C.fg
|
||||||
val secondary = if (focused) C.onFocus else C.muted
|
val secondary = if (focused) C.onFocus else C.muted
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val request = remember(item.poster, context) {
|
val request = remember(item.poster, context) {
|
||||||
item.poster?.let { WolfskinImages.request(context, it, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt(), true) }
|
item.poster?.let { WolfskinImages.request(context, it, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt(), true) }
|
||||||
}
|
}
|
||||||
val posterReady = remember(request) { mutableStateOf(false) }
|
val posterState = remember(request) { mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty) }
|
||||||
Row(Modifier.fillMaxWidth().focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
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))
|
.background(if (focused) C.fg else Color.Transparent, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||||
.pointerInput(item.id) {
|
.then(if (interactive) Modifier.pointerInput(item.id) {
|
||||||
awaitPointerEventScope {
|
awaitPointerEventScope {
|
||||||
while (true) if (awaitPointerEvent().type == PointerEventType.Enter) onFocus()
|
while (true) if (awaitPointerEvent().type == PointerEventType.Enter) onFocus()
|
||||||
}
|
}
|
||||||
}.clickable(onClick = onClick)
|
} else Modifier).clickable(enabled = interactive, onClick = onClick)
|
||||||
.padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY).testTag("coming-row-${item.id}"),
|
.padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY),
|
||||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) {
|
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) {
|
||||||
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null,
|
Box(Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb))
|
||||||
modifier = Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb))
|
.background(if (posterState.value is AsyncImagePainter.State.Success) Color.Transparent else C.surface3)
|
||||||
.background(if (posterReady.value) Color.Transparent else C.surface3),
|
.testTag("coming-thumb-${item.id}"), contentAlignment = Alignment.Center) {
|
||||||
onState = { posterReady.value = it is AsyncImagePainter.State.Success },
|
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null,
|
||||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop)
|
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)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline),
|
Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline),
|
||||||
style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,8 @@ class ShellState(
|
||||||
var peopleStatus by mutableStateOf(PeopleStatus.Ready)
|
var peopleStatus by mutableStateOf(PeopleStatus.Ready)
|
||||||
var recoveryIndex by mutableStateOf(0)
|
var recoveryIndex by mutableStateOf(0)
|
||||||
var requestsVisible by mutableStateOf(false)
|
var requestsVisible by mutableStateOf(false)
|
||||||
|
internal var requestsExpansionInput = 0L
|
||||||
|
private set
|
||||||
var toast by mutableStateOf<String?>(null)
|
var toast by mutableStateOf<String?>(null)
|
||||||
private val personRequests = mutableMapOf<String, ComingInState>()
|
private val personRequests = mutableMapOf<String, ComingInState>()
|
||||||
val requests: ComingInState get() = personRequests.getValue(person.key)
|
val requests: ComingInState get() = personRequests.getValue(person.key)
|
||||||
|
|
@ -161,8 +163,12 @@ class ShellState(
|
||||||
when {
|
when {
|
||||||
key == ShellKey.Back -> closeRequests()
|
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.Unavailable && (requests.snapshot.items.isEmpty() || requests.recoveryFocused) -> retryRequests()
|
||||||
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Loading && requests.snapshot.items.isEmpty() -> closeRequests()
|
key == ShellKey.Ok && requests.snapshot.items.isEmpty() && requests.snapshot.status != ComingInStatus.Unavailable -> closeRequests()
|
||||||
requests.key(key) -> activateRequest()
|
else -> {
|
||||||
|
val expanded = requests.expanded
|
||||||
|
if (requests.key(key)) activateRequest()
|
||||||
|
if ((key == ShellKey.Right || key == ShellKey.Left) && requests.expanded != expanded) requestsExpansionInput++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,12 +96,14 @@ fun ShellSurface(
|
||||||
if (state.pickerVisible) {
|
if (state.pickerVisible) {
|
||||||
WhosWatching(state)
|
WhosWatching(state)
|
||||||
} else {
|
} 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)
|
NavBar(state, view.listState.firstVisibleItemIndex > 0 || view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
|
||||||
if (state.requestsVisible) ComingInSheet(state)
|
ComingInSheet(state)
|
||||||
overlay()
|
overlay()
|
||||||
}
|
}
|
||||||
state.toast?.let { RecoveryToast(it) }
|
RecoveryToastHost(state.toast)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -100,9 +100,9 @@ class RequestsKeysTest {
|
||||||
assertEquals(1, retries)
|
assertEquals(1, retries)
|
||||||
assertTrue(state.requestsVisible)
|
assertTrue(state.requestsVisible)
|
||||||
rule.runOnIdle { state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready)) }
|
rule.runOnIdle { state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready)) }
|
||||||
|
rule.onNodeWithText("New requests show up here.").assertExists()
|
||||||
send(Key.DirectionDown, Key.DirectionCenter)
|
send(Key.DirectionDown, Key.DirectionCenter)
|
||||||
rule.onNodeWithText("Nothing coming in.").assertExists()
|
assertFalse(state.requestsVisible)
|
||||||
assertTrue(state.requestsVisible)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun cachedRatingsRenderInTheTelevisionLocale() {
|
@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