diff --git a/core/designsystem/README.md b/core/designsystem/README.md index 119ff91c1..82709964e 100644 --- a/core/designsystem/README.md +++ b/core/designsystem/README.md @@ -25,6 +25,7 @@ Owns shared Compose visual primitives: theme, typography, shapes, motion, loader - Shared components including `OptimizedImage` (optional `errorContent` for blank/failed art; `showLoadingPlaceholder = false` for decorative layers such as Marquee's blurred backdrop, so the logo placeholder never shows through; optional `onLoaded`, called once a picture has loaded, for a page that waits for its pictures before animating, such as The Rest Is History's gallery lights), loaders, `PillFilterChip` (onboarding/Explore genre pills), `BoxLoreLogo` (optional `height` for hero vs chrome sizes), `RemoveDownloadConfirmationDialog` (Material 3 confirmation dialog before removing downloaded media), player-control primitives used by UI modules, floating 3+1 navigation chrome, bottom-content clearance helpers, sleep-timer chrome, and the non-dismissible `RepairProgressPopup` for app-shell foreground repair status. - `TopBarUtilityActions` groups Feedback and Settings as 33dp-tall solid Material 3 connected action segments, adding Update when available. Settings uses the sliders (`Tune`) icon. Feedback/Settings use 20dp icons in 43dp-wide segments with press feedback; the update symbol is 22dp. Presses retain each segment's connected corners and use the existing restrained scale response without an extra rectangular ripple. The shimmer remains clipped to the Update segment. These remain action buttons rather than mutually exclusive selection controls. Foundation expands touch bounds vertically beyond the compact visual height. Screen owners retain placement, click/long-press callbacks and shortcut visibility; this component owns no scroll behavior. Home's JVM `TopBarUtilityActionsTest` covers actual pressed-corner pixels with/without Update and the click/long-press callbacks. Custom control backgrounds belong inside the press layer so the complete coloured surface contracts together with its content; native Material controls keep their own indication. - `LocalFeedbackReplyWaiting` (the app shell provides it from `SupportRepository.replyWaiting`) lights the Feedback segment while a team reply is unread: primary-container colours and the same shimmer as Update, a 10dp primary dot ringed in the surface at its top-end corner (outside the segment's clip, test tag `FEEDBACK_REPLY_DOT_TAG`), and the label **Feedback, new reply**. With Update showing too, Update keeps the leading corner and Feedback sits in the middle. `TopBarUtilityActionsTest` covers the dot and label appearing with Update showing (and absent before), the click, and, without Update, Feedback taking the leading corner. +- `SleepTimerPopup` is the late-night sleep timer prompt the app shell shows once a night (`AppPlaybackNotices`), "Moonrise" (8 Nov 2026): a `surfaceContainerHigh` card that drops in from the top, capped at 420dp wide and filling a phone's width, with a `primaryContainer` night-sky band (a slowly turning `MaterialShapes.Cookie9Sided` moon in `primary`, three twinkling stars, the question and Dismiss), the times as a connected button group in `secondaryContainer` (the pressed one widens and rounds) and End of episode in `primaryContainer`. A pick sets the timer at once and fills the button in `primary`; the choices fold away, the moon sets behind the band and a `tertiary` badge rises in its place with the time ("45 min", "1 hr", or an end-of-episode mark, `sleepTimerBadge`) beside "Pausing in 45 min" and "Good night." (a polite live region). The prompt leaves `SleepPromptMotion.CONFIRM_HOLD_MILLIS` after the pick (`Confirmation`), on Dismiss before a pick (`Manual`; after a pick Dismiss only ends the confirmation early, reported once as `Confirmation`) or untouched after `autoHideMillis` (`Timeout`). Every colour is a theme role, so it follows light, dark and every preset; copy is in string resources. The motion is `SleepPromptMotion`, pure functions of an entrance clock and a confirmation clock read while drawing, from the approved design board; with animations off each step shows its final state and nothing twinkles or turns. The picked time belongs to one showing: it is saveable, so the confirmation survives rotation, resizing and recreation without setting the timer again, and it leaves with the prompt, so the next showing starts afresh rather than on the last confirmation. A prompt hidden from outside while confirming reports `Confirmation` once. - `LocalAdaptivePlayerCompactProgress` carries the app-owned player/nav morph clock. `Modifier.adaptivePlayerOverlayOffset` reads it only during placement, moving floating feature controls down by the released player row during the shared `adaptivePlayerDockingProgress` phase. Classic and absent playback retain their placement; list/content clearance remains stable. - Regular navigation tabs in both presentations use the current theme's `primary` accent and `onPrimary` icon color through `NavigationAccentColors`, including authored presets, wallpaper palettes and exact custom accents. Classic selected labels use the same accent. Floating Lore retains its multicolour aurora and container base, blended into the single moving indicator during draw rather than adding another selection surface. Its selected icon uses `onPrimary` before the aurora arrives, `onPrimaryContainer` at rest, and the more readable of those foregrounds during the solid-base blend; foreground selection follows the same indicator position without a second delayed color animation. Unselected icons retain `onSurfaceVariant`. - `icon.GenreIcons`: Rich icon palette and resolution helpers for custom podcast genre tags, subscription folders, and filter chips. Maps icon keys to Material Rounded icons (`all`, `findIcon`, `defaultGenreIcon`, `iconOrFallback`, `defaultFolderIcon`, `folderIconOrFallback`). @@ -140,6 +141,7 @@ Classic navigation applies its system insets outside the minimum 80dp content he ```bash ./gradlew :core:designsystem:testDebugUnitTest ``` +- `SleepPromptMotionTest` covers the prompt's entrance (dropping in, the moon turning in, the times popping in one after another, all at rest by the end), the confirmation (the moon setting while the badge rises, so the band is never empty; the question, choices and moon gone and the answer shown well before the prompt leaves), the stars' twinkle range and turns, and the badge's hours/minutes/end-of-episode rule. `SleepTimerPopupTest` drives the prompt: every option shown, a pick setting the timer once and confirming when playback will pause before `Confirmation`, Dismiss (`Manual`) and the eight-second `Timeout`, the confirmation surviving state restoration without a second pick, a prompt shown again after a pick starting afresh (question and times laid out, no old confirmation; a regression test for the prompt reopening empty), a prompt hidden mid-confirmation reporting `Confirmation` once, Dismiss after a pick reporting only `Confirmation`, a prompt dismissed just before its timeout not also reporting `Timeout`, the faded question and folded times leaving the accessibility tree after a pick, with system animations off every step at its final state at once, and the width filling a 360dp window and capping at 420dp on an 840dp one through window changes both ways mid-confirmation. ## CI relevance diff --git a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/SleepPromptMotion.kt b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/SleepPromptMotion.kt new file mode 100644 index 000000000..bf6de2bea --- /dev/null +++ b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/SleepPromptMotion.kt @@ -0,0 +1,92 @@ +package cx.aswin.boxlore.core.designsystem.components + +import androidx.compose.animation.core.CubicBezierEasing +import kotlin.math.PI +import kotlin.math.cos + +/** + * The sleep timer prompt's motion, from the approved design board, in milliseconds: the entrance + * from the moment it shows, the confirmation from the moment a time is picked. Each part is a pure + * function of its clock, read while drawing. + */ +internal object SleepPromptMotion { + const val ENTER_MILLIS = 1_000f + const val CONFIRM_MILLIS = 1_700f + + /** How long the confirmation stays after a pick before the prompt leaves. */ + const val CONFIRM_HOLD_MILLIS = 3_000L + const val EXIT_MILLIS = 320 + const val SPIN_MILLIS = 24_000 + + data class Pose(val alpha: Float = 1f, val scale: Float = 1f, val rotation: Float = 0f, val offsetDp: Float = 0f) + + data class Glow(val alpha: Float, val scale: Float) + + /** A star: how far in from the band's end, how far down it (as a fraction of its height), and its size. */ + data class Star(val fromEnd: Float, val down: Float, val radius: Float) + + val Stars = listOf(Star(26f, 0.45f, 7f), Star(38f, 0.72f, 5f), Star(14f, 0.86f, 4f)) + + private val Drop = CubicBezierEasing(0.2f, 1.2f, 0.3f, 1f) + private val Bounce = CubicBezierEasing(0.2f, 1.3f, 0.3f, 1f) + private val Pop = CubicBezierEasing(0.3f, 1.5f, 0.4f, 1f) + private val Settle = CubicBezierEasing(0.2f, 0.8f, 0.3f, 1f) + private val Fold = CubicBezierEasing(0.3f, 1f, 0.4f, 1f) + private val Sink = CubicBezierEasing(0.5f, 0f, 0.75f, 0f) + + /** The card drops in from just above its place, scaling up from 92%. */ + fun card(t: Float): Pose { + val e = Drop.transform(progress(t, 0f, 640f)) + return Pose(alpha = progress(t, 0f, 190f), scale = 0.92f + 0.08f * e, offsetDp = -28f + 28f * e) + } + + /** The moon turns and grows into place. */ + fun moon(t: Float): Pose { + val e = Bounce.transform(progress(t, 120f, 700f)) + return Pose(alpha = e.coerceIn(0f, 1f), scale = 0.4f + 0.6f * e, rotation = -90f + 90f * e) + } + + /** Each time pops in after the one before it. */ + fun buttonPop(t: Float, index: Int): Pose { + val e = Pop.transform(progress(t, 320f + index * 45f, 380f)) + return Pose(alpha = e.coerceIn(0f, 1f), scale = 0.82f + 0.18f * e) + } + + /** End of episode rises in last. */ + fun endPop(t: Float): Pose { + val e = Settle.transform(progress(t, 520f, 320f)) + return Pose(alpha = e.coerceIn(0f, 1f), offsetDp = 8f * (1f - e)) + } + + /** The question fades as the answer takes its place. */ + fun questionAlpha(c: Float): Float = 1f - progress(c, 300f, 180f) + + /** How much of the choices' height is still laid out, 1 to 0, as the card folds up. */ + fun choicesHeight(c: Float): Float = 1f - Fold.transform(progress(c, 300f, 460f)) + + fun choicesAlpha(c: Float): Float = 1f - progress(c, 300f, 200f) + + /** How far the moon has set below its place, in dp: out of the band by the end. */ + fun moonSet(c: Float): Float = 110f * Sink.transform(progress(c, 480f, 760f)) + + /** The time badge rising into the moon's place as it sets, turning as it comes. */ + fun badge(c: Float): Pose { + val e = Bounce.transform(progress(c, 860f, 760f)) + return Pose(offsetDp = 110f * (1f - e), rotation = 40f * (1f - e)) + } + + /** "Pausing in … Good night." fades up once the badge is on its way. */ + fun answer(c: Float): Pose { + val e = Settle.transform(progress(c, 980f, 320f)) + return Pose(alpha = e.coerceIn(0f, 1f), offsetDp = 6f * (1f - e)) + } + + /** Star [index]'s brightness and size at [phase] (0 to 1) of the twinkle cycle; the three take turns. */ + fun twinkle(phase: Float, index: Int): Glow { + val local = (phase + index / 3f) % 1f + val v = 0.5f - 0.5f * cos(2f * PI.toFloat() * local) + return Glow(alpha = 0.35f + 0.65f * v, scale = 0.75f + 0.25f * v) + } + + private fun progress(t: Float, start: Float, duration: Float): Float = ((t - start) / duration).coerceIn(0f, 1f) +} diff --git a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/SleepTimerPopup.kt b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/SleepTimerPopup.kt index cc8428db6..7a5fa1672 100644 --- a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/SleepTimerPopup.kt +++ b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/SleepTimerPopup.kt @@ -1,47 +1,83 @@ package cx.aswin.boxlore.core.designsystem.components +import android.animation.ValueAnimator import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Bedtime import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.SkipNext +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.ButtonShapes +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.toShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import cx.aswin.boxlore.core.designsystem.theme.ExpressiveMotion -import cx.aswin.boxlore.core.designsystem.theme.ExpressivePressFeedback -import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight -import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable +import androidx.compose.ui.unit.sp +import cx.aswin.boxlore.core.designsystem.R import cx.aswin.boxlore.core.model.SleepTimerConstants +import kotlin.math.roundToInt import kotlinx.coroutines.delay /** A single selectable duration option in the sleep timer popup. 999 means "End of episode". */ @@ -62,9 +98,13 @@ val DefaultSleepTimerOptions = listOf( ) /** - * Dynamic-island style popup for the late-night sleep timer nudge. Springs in from the - * top-center with options visible up front (no tap-to-reveal). Auto-hides after ~8s of - * inactivity, and shows a brief confirmation before dismissing once an option is picked. + * The late-night sleep timer nudge, "Moonrise": a card in the theme's colours that drops in from + * the top with a night-sky band (a slowly turning nine-scalloped moon and twinkling stars), the + * question, the times as a connected button group and End of episode under it. Picking one sets + * the timer at once: the buttons fold away, the moon sets behind the band and a seafoam badge + * rises in its place with the time picked, beside "Pausing in 45 min. Good night." Then the card + * leaves. It hides itself after [autoHideMillis] untouched. With animations off every step shows + * its final state. */ @Composable fun SleepTimerPopup( @@ -75,269 +115,424 @@ fun SleepTimerPopup( onSelectDuration: (Int) -> Unit, onDismiss: (SleepTimerPopupDismissReason) -> Unit ) { - var isConfirming by remember { mutableStateOf(false) } - - LaunchedEffect(visible) { - if (visible) { - isConfirming = false - } else { - if (isConfirming) { - onDismiss(SleepTimerPopupDismissReason.Confirmation) - } - } - } - - LaunchedEffect(visible, isConfirming) { - if (visible && !isConfirming) { - delay(autoHideMillis) - onDismiss(SleepTimerPopupDismissReason.Timeout) - } - } - - LaunchedEffect(isConfirming) { - if (isConfirming) { - delay(1_800L) - onDismiss(SleepTimerPopupDismissReason.Confirmation) - } - } + val dismiss by rememberUpdatedState(onDismiss) + val select by rememberUpdatedState(onSelectDuration) + val shown by rememberUpdatedState(visible) AnimatedVisibility( visible = visible, - enter = fadeIn(animationSpec = tween(220)) + - scaleIn( - initialScale = 0.82f, - transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0f), - animationSpec = ExpressiveMotion.FormalSpring - ), - exit = fadeOut(animationSpec = tween(180)) + - scaleOut( - targetScale = 0.88f, - transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0f) - ), + enter = EnterTransition.None, + exit = fadeOut(tween(SleepPromptMotion.EXIT_MILLIS)) + + slideOutVertically(tween(SleepPromptMotion.EXIT_MILLIS)) { -it / 5 } + + scaleOut(tween(SleepPromptMotion.EXIT_MILLIS), targetScale = 0.96f), modifier = modifier ) { - SleepTimerSurface( - isConfirming = isConfirming, - palette = SleepTimerPopupPalette(), - durationOptions = options.filterNot { it.isEndOfEpisode }, - endOfEpisodeOption = options.firstOrNull { it.isEndOfEpisode }, - onSelectDuration = { minutes -> - onSelectDuration(minutes) - isConfirming = true + // One showing's own state: it leaves with the prompt, so the next showing starts afresh, + // and it survives a configuration change while shown, so a rotation keeps the confirmation. + var picked by rememberSaveable { mutableStateOf(null) } + var confirmed by rememberSaveable { mutableStateOf(false) } + val clocks = rememberSleepPromptClocks(picked) + LaunchedEffect(picked) { + if (picked == null) { + delay(autoHideMillis) + // Read now, not when the timer started: a prompt already on its way out times out silently. + if (shown) dismiss(SleepTimerPopupDismissReason.Timeout) + } else if (!confirmed) { + delay(SleepPromptMotion.CONFIRM_HOLD_MILLIS) + confirmed = true + dismiss(SleepTimerPopupDismissReason.Confirmation) + } + } + // Hidden from outside while confirming: the confirmation still counts, once. + LaunchedEffect(visible) { + if (!visible && picked != null && !confirmed) { + confirmed = true + dismiss(SleepTimerPopupDismissReason.Confirmation) + } + } + SleepPromptCard( + options = options, + picked = picked, + clocks = clocks, + onPick = { minutes -> + if (picked == null) { + picked = minutes + select(minutes) + } + }, + onDismiss = { + // Before a pick it is a dismissal; after one it only ends the confirmation early. + when { + picked == null -> dismiss(SleepTimerPopupDismissReason.Manual) + !confirmed -> { + confirmed = true + dismiss(SleepTimerPopupDismissReason.Confirmation) + } + } }, - onDismiss = onDismiss ) } } -private val SleepTimerOption.isEndOfEpisode: Boolean - get() = minutes == SleepTimerConstants.END_OF_EPISODE_MINUTES - -private data class SleepTimerPopupPalette( - val islandColor: Color = Color(0xFF161618), - val islandBorder: Color = Color.White.copy(alpha = 0.14f), - val onIsland: Color = Color.White, - val onIslandMuted: Color = Color.White.copy(alpha = 0.62f) -) +/** The prompt's two clocks, in milliseconds: its entrance, and the confirmation after a pick. */ +private class SleepPromptClocks(val enter: State, val confirm: State, val still: Boolean) @Composable -private fun SleepTimerSurface( - isConfirming: Boolean, - palette: SleepTimerPopupPalette, - durationOptions: List, - endOfEpisodeOption: SleepTimerOption?, - onSelectDuration: (Int) -> Unit, - onDismiss: (SleepTimerPopupDismissReason) -> Unit -) { - Surface( - shape = RoundedCornerShape(30.dp), - color = palette.islandColor, - contentColor = palette.onIsland, - shadowElevation = 20.dp, - border = androidx.compose.foundation.BorderStroke(1.dp, palette.islandBorder), - modifier = Modifier - .fillMaxWidth() - .widthIn(max = 420.dp) - ) { - if (isConfirming) { - SleepTimerConfirmation(palette) +private fun rememberSleepPromptClocks(picked: Int?): SleepPromptClocks { + val still = remember { !ValueAnimator.areAnimatorsEnabled() } + val enter = remember { Animatable(if (still) SleepPromptMotion.ENTER_MILLIS else 0f) } + // Nothing to confirm until a pick: the clock starts at 0 even with animations off. + val confirm = remember { Animatable(if (picked != null) SleepPromptMotion.CONFIRM_MILLIS else 0f) } + LaunchedEffect(Unit) { + if (!still) enter.animateTo(SleepPromptMotion.ENTER_MILLIS, tween(SleepPromptMotion.ENTER_MILLIS.toInt(), easing = LinearEasing)) + } + LaunchedEffect(picked) { + if (picked == null || confirm.value >= SleepPromptMotion.CONFIRM_MILLIS) return@LaunchedEffect + if (still) { + confirm.snapTo(SleepPromptMotion.CONFIRM_MILLIS) } else { - SleepTimerOptionsContent( - palette = palette, - durationOptions = durationOptions, - endOfEpisodeOption = endOfEpisodeOption, - onSelectDuration = onSelectDuration, - onDismiss = onDismiss - ) + confirm.animateTo(SleepPromptMotion.CONFIRM_MILLIS, tween(SleepPromptMotion.CONFIRM_MILLIS.toInt(), easing = LinearEasing)) } } + return remember(enter, confirm) { SleepPromptClocks(enter.asState(), confirm.asState(), still) } } @Composable -private fun SleepTimerConfirmation(palette: SleepTimerPopupPalette) { - Row( +private fun SleepPromptCard( + options: List, + picked: Int?, + clocks: SleepPromptClocks, + onPick: (Int) -> Unit, + onDismiss: () -> Unit, +) { + Surface( + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurface, + shadowElevation = 8.dp, + // Capped first, then filled: a phone gets the full width, a wide window a card-sized prompt. modifier = Modifier + .widthIn(max = 420.dp) .fillMaxWidth() - .padding(horizontal = 22.dp, vertical = 22.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically + .graphicsLayer { + val pose = SleepPromptMotion.card(clocks.enter.value) + alpha = pose.alpha + translationY = pose.offsetDp * density + scaleX = pose.scale + scaleY = pose.scale + transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0f) + }, ) { - Icon( - imageVector = Icons.Rounded.Bedtime, - contentDescription = null, - tint = palette.onIsland, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Sleep timer set. Good night!", - style = MaterialTheme.typography.titleSmall, - fontWeight = GoogleSansWeight.bold, - color = palette.onIsland - ) + Column(Modifier.padding(12.dp)) { + NightBand(picked = picked, clocks = clocks, onDismiss = onDismiss) + SleepPromptChoices( + options = options, + picked = picked, + clocks = clocks, + onPick = onPick, + modifier = Modifier.collapseBy { SleepPromptMotion.choicesHeight(clocks.confirm.value) }, + ) + } } } +/** The night sky: stars, the moon (or, after a pick, the time badge), the question or the answer, and Dismiss. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun SleepTimerOptionsContent( - palette: SleepTimerPopupPalette, - durationOptions: List, - endOfEpisodeOption: SleepTimerOption?, - onSelectDuration: (Int) -> Unit, - onDismiss: (SleepTimerPopupDismissReason) -> Unit -) { - Column( - modifier = Modifier +private fun NightBand(picked: Int?, clocks: SleepPromptClocks, onDismiss: () -> Unit) { + val colors = MaterialTheme.colorScheme + val spin = rememberSpin(clocks.still) + Box( + Modifier .fillMaxWidth() - .padding(horizontal = 18.dp, vertical = 18.dp) + .heightIn(min = 100.dp) + .clip(RoundedCornerShape(20.dp)) + .background(colors.primaryContainer), ) { - SleepTimerPopupHeader(palette, onDismiss) - Spacer(modifier = Modifier.height(16.dp)) - DurationOptionsRow( - options = durationOptions, - palette = palette, - onSelectDuration = onSelectDuration - ) - EndOfEpisodeOption( - option = endOfEpisodeOption, - palette = palette, - onSelectDuration = onSelectDuration + Stars( + color = colors.onPrimaryContainer, + fast = picked != null, + still = clocks.still, + modifier = Modifier.matchParentSize(), ) - } -} - -@Composable -private fun SleepTimerPopupHeader(palette: SleepTimerPopupPalette, onDismiss: (SleepTimerPopupDismissReason) -> Unit) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top - ) { - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.Top + val cookie = MaterialShapes.Cookie9Sided.toShape() + Box( + Modifier + .align(Alignment.CenterStart) + .padding(start = 14.dp) + .size(64.dp) + .graphicsLayer { + val enter = SleepPromptMotion.moon(clocks.enter.value) + alpha = enter.alpha + scaleX = enter.scale + scaleY = enter.scale + rotationZ = enter.rotation + translationY = SleepPromptMotion.moonSet(clocks.confirm.value) * density + }, + contentAlignment = Alignment.Center, ) { - Box( + Box(Modifier.size(64.dp).graphicsLayer { rotationZ = spin.value }.background(colors.primary, cookie)) + Icon(Icons.Rounded.Bedtime, contentDescription = null, tint = colors.onPrimary, modifier = Modifier.size(30.dp)) + } + if (picked != null) { + TimeBadge( + minutes = picked, + cookie = cookie, + spin = spin, modifier = Modifier - .size(36.dp) - .background(Color.White.copy(alpha = 0.10f), CircleShape), - contentAlignment = Alignment.Center + .align(Alignment.CenterStart) + .padding(start = 14.dp) + .graphicsLayer { + val rise = SleepPromptMotion.badge(clocks.confirm.value) + translationY = rise.offsetDp * density + rotationZ = rise.rotation + }, + ) + } + Column( + Modifier + .padding(start = 92.dp, end = 48.dp, top = 16.dp, bottom = 16.dp) + .graphicsLayer { alpha = SleepPromptMotion.questionAlpha(clocks.confirm.value) } + // Faded out after a pick: screen readers hear the answer only. + .then(if (picked != null) Modifier.clearAndSetSemantics {} else Modifier), + ) { + Text( + stringResource(R.string.sleep_prompt_title), + style = MaterialTheme.typography.titleMedium, + color = colors.onPrimaryContainer, + ) + Text( + stringResource(R.string.sleep_prompt_body), + style = MaterialTheme.typography.bodyMedium, + color = colors.onPrimaryContainer, + modifier = Modifier.padding(top = 2.dp), + ) + } + if (picked != null) { + Column( + Modifier + .align(Alignment.CenterStart) + .padding(start = 92.dp, end = 48.dp) + .graphicsLayer { + val answer = SleepPromptMotion.answer(clocks.confirm.value) + alpha = answer.alpha + translationY = answer.offsetDp * density + } + .semantics { liveRegion = LiveRegionMode.Polite }, ) { - Icon( - imageVector = Icons.Rounded.Bedtime, - contentDescription = null, - tint = palette.onIsland, - modifier = Modifier.size(18.dp) - ) - } - Spacer(modifier = Modifier.width(12.dp)) - Column { Text( - text = "Late night listening?", - style = MaterialTheme.typography.titleSmall, - fontWeight = GoogleSansWeight.bold, - color = palette.onIsland + pausingText(picked), + style = MaterialTheme.typography.titleMedium, + color = colors.onPrimaryContainer, ) - Spacer(modifier = Modifier.height(3.dp)) Text( - text = "Set a sleep timer so episodes don't keep playing overnight.", - style = MaterialTheme.typography.bodySmall, - color = palette.onIslandMuted + stringResource(R.string.sleep_prompt_good_night), + style = MaterialTheme.typography.bodyMedium, + color = colors.onPrimaryContainer, + modifier = Modifier.padding(top = 2.dp), ) } } + IconButton(onClick = onDismiss, modifier = Modifier.align(Alignment.TopEnd).padding(top = 4.dp, end = 4.dp).size(40.dp)) { + Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.sleep_prompt_dismiss), tint = colors.onPrimaryContainer, modifier = Modifier.size(20.dp)) + } + } +} - Box( - modifier = Modifier - .size(30.dp) - .expressiveClickable(feedback = ExpressivePressFeedback.Control, shape = CircleShape) { - onDismiss(SleepTimerPopupDismissReason.Manual) - }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Rounded.Close, - contentDescription = "Dismiss", - tint = palette.onIslandMuted, - modifier = Modifier.size(16.dp) - ) +/** The picked time on a seafoam scalloped badge: "45 min", "1 hr", or the end-of-episode mark. */ +@Composable +private fun TimeBadge(minutes: Int, cookie: androidx.compose.ui.graphics.Shape, spin: State, modifier: Modifier) { + val colors = MaterialTheme.colorScheme + val badge = sleepTimerBadge(minutes) + Box(modifier.size(64.dp).clearAndSetSemantics {}, contentAlignment = Alignment.Center) { + Box(Modifier.size(64.dp).graphicsLayer { rotationZ = spin.value }.background(colors.tertiary, cookie)) + when (badge) { + SleepTimerBadge.EndOfEpisode -> Icon(Icons.Rounded.SkipNext, contentDescription = null, tint = colors.onTertiary, modifier = Modifier.size(26.dp)) + is SleepTimerBadge.Time -> Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(badge.amount.toString(), color = colors.onTertiary, fontSize = 20.sp, lineHeight = 20.sp, style = MaterialTheme.typography.titleLarge) + Text( + stringResource(if (badge.hours) R.string.sleep_prompt_unit_hours else R.string.sleep_prompt_unit_minutes), + color = colors.onTertiary, + style = MaterialTheme.typography.labelSmall, + ) + } } } } +/** The times as a connected button group, End of episode under it; each pops in, and the picked one fills. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun DurationOptionsRow(options: List, palette: SleepTimerPopupPalette, onSelectDuration: (Int) -> Unit) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) +private fun SleepPromptChoices(options: List, picked: Int?, clocks: SleepPromptClocks, onPick: (Int) -> Unit, modifier: Modifier) { + val times = options.filterNot { it.minutes == SleepTimerConstants.END_OF_EPISODE_MINUTES } + val endOfEpisode = options.firstOrNull { it.minutes == SleepTimerConstants.END_OF_EPISODE_MINUTES } + Column( + modifier + .graphicsLayer { alpha = SleepPromptMotion.choicesAlpha(clocks.confirm.value) } + // Folded away after a pick: the old times are no longer reachable. + .then(if (picked != null) Modifier.clearAndSetSemantics {} else Modifier), ) { - options.forEach { option -> - SleepTimerOptionChip( - label = option.label, - modifier = Modifier.weight(1f), - textStyle = MaterialTheme.typography.labelMedium, - palette = palette, - onClick = { onSelectDuration(option.minutes) } - ) + Spacer(Modifier.height(12.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween)) { + times.forEachIndexed { index, option -> + TimeButton( + option = option, + position = when (index) { + 0 -> GroupPosition.Leading + times.lastIndex -> GroupPosition.Trailing + else -> GroupPosition.Middle + }, + picked = picked == option.minutes, + pop = { SleepPromptMotion.buttonPop(clocks.enter.value, index) }, + onClick = { onPick(option.minutes) }, + ) + } + } + if (endOfEpisode != null) { + Spacer(Modifier.height(8.dp)) + val colors = MaterialTheme.colorScheme + val chosen = picked == endOfEpisode.minutes + val container by animateColorAsState(if (chosen) colors.primary else colors.primaryContainer, label = "endContainer") + val content by animateColorAsState(if (chosen) colors.onPrimary else colors.onPrimaryContainer, label = "endContent") + Button( + onClick = { onPick(endOfEpisode.minutes) }, + shapes = ButtonShapes(RoundedCornerShape(24.dp), RoundedCornerShape(16.dp)), + colors = ButtonDefaults.buttonColors(containerColor = container, contentColor = content), + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .graphicsLayer { + val pop = SleepPromptMotion.endPop(clocks.enter.value) + alpha = pop.alpha + translationY = pop.offsetDp * density + }, + ) { + Icon(Icons.Rounded.SkipNext, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(endOfEpisode.label, style = MaterialTheme.typography.labelLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + } } } } -@Composable -private fun EndOfEpisodeOption(option: SleepTimerOption?, palette: SleepTimerPopupPalette, onSelectDuration: (Int) -> Unit) { - if (option == null) return +private enum class GroupPosition { Leading, Middle, Trailing } - Spacer(modifier = Modifier.height(10.dp)) - SleepTimerOptionChip( - label = option.label, - modifier = Modifier.fillMaxWidth(), - textStyle = MaterialTheme.typography.labelLarge, - palette = palette, - onClick = { onSelectDuration(option.minutes) } +/** One time in the group: connected corners at rest, round while pressed or once picked; it widens as it is pressed and picked. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun androidx.compose.foundation.layout.RowScope.TimeButton( + option: SleepTimerOption, + position: GroupPosition, + picked: Boolean, + pop: () -> SleepPromptMotion.Pose, + onClick: () -> Unit, +) { + val colors = MaterialTheme.colorScheme + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + val weight by animateFloatAsState( + targetValue = when { + picked -> 2f + pressed -> 1.4f + else -> 1f + }, + animationSpec = spring(dampingRatio = 0.6f, stiffness = 500f), + label = "timeWeight", ) + val container by animateColorAsState(if (picked) colors.primary else colors.secondaryContainer, label = "timeContainer") + val content by animateColorAsState(if (picked) colors.onPrimary else colors.onSecondaryContainer, label = "timeContent") + val outer = 24.dp + val inner = 8.dp + val rest = when (position) { + GroupPosition.Leading -> RoundedCornerShape(topStart = outer, bottomStart = outer, topEnd = inner, bottomEnd = inner) + GroupPosition.Middle -> RoundedCornerShape(inner) + GroupPosition.Trailing -> RoundedCornerShape(topStart = inner, bottomStart = inner, topEnd = outer, bottomEnd = outer) + } + Button( + onClick = onClick, + shapes = ButtonShapes(if (picked) RoundedCornerShape(outer) else rest, RoundedCornerShape(outer)), + colors = ButtonDefaults.buttonColors(containerColor = container, contentColor = content), + contentPadding = PaddingValues(horizontal = 4.dp), + interactionSource = interaction, + modifier = Modifier + .weight(weight) + .height(48.dp) + .graphicsLayer { + val p = pop() + alpha = p.alpha + scaleX = p.scale + scaleY = p.scale + }, + ) { + Text(option.label, style = MaterialTheme.typography.labelLarge, maxLines = 1, overflow = TextOverflow.Clip) + } } +/** Three four-point stars at the band's open end, twinkling in turn (faster once a time is picked). */ @Composable -private fun SleepTimerOptionChip( - label: String, - modifier: Modifier, - textStyle: androidx.compose.ui.text.TextStyle, - palette: SleepTimerPopupPalette, - onClick: () -> Unit -) { - Box( - modifier = modifier - .background(Color.White.copy(alpha = 0.10f), RoundedCornerShape(16.dp)) - .expressiveClickable(feedback = ExpressivePressFeedback.Control, shape = RoundedCornerShape(16.dp), onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = label, - style = textStyle, - fontWeight = GoogleSansWeight.bold, - color = palette.onIsland, - maxLines = 1 +private fun Stars(color: Color, fast: Boolean, still: Boolean, modifier: Modifier) { + val twinkle = if (still) { + remember { mutableStateOf(0.5f) } + } else { + val transition = rememberInfiniteTransition(label = "stars") + transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(if (fast) 900 else 2_400, easing = LinearEasing), RepeatMode.Restart), + label = "twinkle", ) } + Canvas(modifier.clearAndSetSemantics {}) { + val unit = 1.dp.toPx() + SleepPromptMotion.Stars.forEachIndexed { i, star -> + val glow = SleepPromptMotion.twinkle(twinkle.value, i) + val centre = Offset(size.width - star.fromEnd * unit, size.height * star.down) + val r = star.radius * unit * glow.scale + val path = Path().apply { + moveTo(centre.x, centre.y - r) + quadraticTo(centre.x, centre.y, centre.x + r, centre.y) + quadraticTo(centre.x, centre.y, centre.x, centre.y + r) + quadraticTo(centre.x, centre.y, centre.x - r, centre.y) + quadraticTo(centre.x, centre.y, centre.x, centre.y - r) + close() + } + drawPath(path, color, alpha = glow.alpha) + } + } +} + +/** The moon's and the badge's slow turn: one round every 24 seconds; still with animations off. */ +@Composable +private fun rememberSpin(still: Boolean): State { + if (still) return remember { mutableStateOf(0f) } + val transition = rememberInfiniteTransition(label = "spin") + return transition.animateFloat(0f, 360f, infiniteRepeatable(tween(SleepPromptMotion.SPIN_MILLIS, easing = LinearEasing)), label = "spin") +} + +/** Lays out [fraction] of the content's height (0 to 1), so the card folds up as the choices go. */ +private fun Modifier.collapseBy(fraction: () -> Float): Modifier = layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val height = (placeable.height * fraction().coerceIn(0f, 1f)).roundToInt() + layout(placeable.width, height) { placeable.place(0, 0) } +} + +@Composable +private fun pausingText(minutes: Int): String = when (val badge = sleepTimerBadge(minutes)) { + SleepTimerBadge.EndOfEpisode -> stringResource(R.string.sleep_prompt_pausing_episode) + is SleepTimerBadge.Time -> stringResource( + if (badge.hours) R.string.sleep_prompt_pausing_hours else R.string.sleep_prompt_pausing_minutes, + badge.amount, + ) +} + +/** What the badge shows for a picked time: whole hours as hours, anything else as minutes, or the end of the episode. */ +internal sealed interface SleepTimerBadge { + data class Time(val amount: Int, val hours: Boolean) : SleepTimerBadge + data object EndOfEpisode : SleepTimerBadge +} + +internal fun sleepTimerBadge(minutes: Int): SleepTimerBadge = when { + minutes == SleepTimerConstants.END_OF_EPISODE_MINUTES -> SleepTimerBadge.EndOfEpisode + minutes >= 60 && minutes % 60 == 0 -> SleepTimerBadge.Time(minutes / 60, hours = true) + else -> SleepTimerBadge.Time(minutes, hours = false) } diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml index c0935ce98..210311442 100644 --- a/core/designsystem/src/main/res/values/strings.xml +++ b/core/designsystem/src/main/res/values/strings.xml @@ -7,4 +7,16 @@ Listen on No app could open this link + + Late night listening? + Set a sleep timer so episodes don\'t keep playing overnight. + Dismiss + + Pausing in %1$d min + Pausing in %1$d hr + Pausing after this episode + Good night. + + min + hr diff --git a/core/designsystem/src/test/java/cx/aswin/boxlore/core/designsystem/components/SleepPromptMotionTest.kt b/core/designsystem/src/test/java/cx/aswin/boxlore/core/designsystem/components/SleepPromptMotionTest.kt new file mode 100644 index 000000000..30f270484 --- /dev/null +++ b/core/designsystem/src/test/java/cx/aswin/boxlore/core/designsystem/components/SleepPromptMotionTest.kt @@ -0,0 +1,72 @@ +package cx.aswin.boxlore.core.designsystem.components + +import cx.aswin.boxlore.core.model.SleepTimerConstants +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SleepPromptMotionTest { + @Test + fun `the prompt drops in, the moon turns into place and the choices pop in after it`() { + val start = SleepPromptMotion.card(0f) + assertEquals(0f, start.alpha) + assertEquals(-28f, start.offsetDp, 0.01f) + assertEquals(0f, SleepPromptMotion.moon(0f).alpha) + assertEquals(0f, SleepPromptMotion.buttonPop(0f, 0).alpha) + assertEquals(0f, SleepPromptMotion.endPop(0f).alpha) + // The times pop in one after another. + assertTrue(SleepPromptMotion.buttonPop(400f, 0).alpha > SleepPromptMotion.buttonPop(400f, 3).alpha) + val end = SleepPromptMotion.ENTER_MILLIS + assertEquals(SleepPromptMotion.Pose(), SleepPromptMotion.card(end)) + assertEquals(SleepPromptMotion.Pose(), SleepPromptMotion.moon(end)) + for (i in 0..3) assertEquals(SleepPromptMotion.Pose(), SleepPromptMotion.buttonPop(end, i)) + assertEquals(SleepPromptMotion.Pose(), SleepPromptMotion.endPop(end)) + } + + @Test + fun `after a pick the moon sets, the badge rises in its place and the answer replaces the question`() { + // At the pick: the question and choices are there, the moon in place, the badge below the band. + assertEquals(1f, SleepPromptMotion.questionAlpha(0f)) + assertEquals(1f, SleepPromptMotion.choicesHeight(0f)) + assertEquals(0f, SleepPromptMotion.moonSet(0f)) + assertEquals(110f, SleepPromptMotion.badge(0f).offsetDp, 0.01f) + assertEquals(0f, SleepPromptMotion.answer(0f).alpha) + // The badge starts rising before the moon has gone, so the band is never empty. + assertTrue(SleepPromptMotion.badge(1_100f).offsetDp < 110f) + assertTrue(SleepPromptMotion.moonSet(1_100f) < 110f) + val done = SleepPromptMotion.CONFIRM_MILLIS + assertEquals(0f, SleepPromptMotion.questionAlpha(done)) + assertEquals(0f, SleepPromptMotion.choicesHeight(done), 0.001f) + assertEquals(0f, SleepPromptMotion.choicesAlpha(done)) + assertEquals(110f, SleepPromptMotion.moonSet(done), 0.01f) + assertEquals(SleepPromptMotion.Pose(), SleepPromptMotion.badge(done)) + assertEquals(SleepPromptMotion.Pose(), SleepPromptMotion.answer(done)) + // The confirmation is fully shown well before the prompt leaves. + assertTrue(SleepPromptMotion.CONFIRM_HOLD_MILLIS >= done + 1_000) + } + + @Test + fun `the stars take turns to twinkle and stay within their range`() { + for (step in 0..20) { + for (i in SleepPromptMotion.Stars.indices) { + val glow = SleepPromptMotion.twinkle(step / 20f, i) + assertTrue(glow.alpha in 0.35f..1f) + assertTrue(glow.scale in 0.75f..1f) + } + } + // At 0.1 the three stars sit at clearly different points of the cycle. + val phase = 0.1f + val brightness = SleepPromptMotion.Stars.indices.map { SleepPromptMotion.twinkle(phase, it).alpha } + assertTrue(brightness.distinct().size == brightness.size) + } + + @Test + fun `the badge shows whole hours as hours, other times as minutes, and the end of the episode as a mark`() { + assertEquals(SleepTimerBadge.Time(30, hours = false), sleepTimerBadge(30)) + assertEquals(SleepTimerBadge.Time(45, hours = false), sleepTimerBadge(45)) + assertEquals(SleepTimerBadge.Time(1, hours = true), sleepTimerBadge(60)) + assertEquals(SleepTimerBadge.Time(2, hours = true), sleepTimerBadge(120)) + assertEquals(SleepTimerBadge.Time(90, hours = false), sleepTimerBadge(90)) + assertEquals(SleepTimerBadge.EndOfEpisode, sleepTimerBadge(SleepTimerConstants.END_OF_EPISODE_MINUTES)) + } +} diff --git a/core/designsystem/src/test/java/cx/aswin/boxlore/core/designsystem/components/SleepTimerPopupTest.kt b/core/designsystem/src/test/java/cx/aswin/boxlore/core/designsystem/components/SleepTimerPopupTest.kt new file mode 100644 index 000000000..4e588e29d --- /dev/null +++ b/core/designsystem/src/test/java/cx/aswin/boxlore/core/designsystem/components/SleepTimerPopupTest.kt @@ -0,0 +1,234 @@ +package cx.aswin.boxlore.core.designsystem.components + +import android.animation.ValueAnimator +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.core.designsystem.theme.BoxLoreTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +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.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], qualifiers = "w900dp-h900dp-mdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class SleepTimerPopupTest { + @get:Rule val compose = createComposeRule() + + private val picked = mutableListOf() + private val dismissed = mutableListOf() + private var window by mutableStateOf(360.dp) + private var shown by mutableStateOf(true) + + private fun show(restoration: StateRestorationTester? = null) { + compose.mainClock.autoAdvance = false + val content: @androidx.compose.runtime.Composable () -> Unit = { + BoxLoreTheme(darkTheme = true, dynamicColor = false) { + Box(Modifier.width(window)) { + SleepTimerPopup( + visible = shown, + modifier = Modifier.padding(horizontal = 16.dp).testTag(PROMPT), + onSelectDuration = { picked += it }, + onDismiss = { dismissed += it }, + ) + } + } + } + if (restoration != null) restoration.setContent(content) else compose.setContent(content) + compose.mainClock.advanceTimeBy(1_200L) + } + + @Test + fun `picking a time sets it at once, confirms when playback will pause, then lets go`() { + show() + compose.onNodeWithText("Late night listening?").assertExists() + listOf("30m", "45m", "1h", "2h", "End of episode").forEach { compose.onNodeWithText(it).assertExists() } + compose.onNodeWithText("45m").performClick() + assertEquals(listOf(45), picked) + compose.mainClock.advanceTimeBy(1_800L) + compose.onNodeWithText("Pausing in 45 min").assertExists() + compose.onNodeWithText("Good night.").assertExists() + assertTrue(dismissed.isEmpty()) + compose.mainClock.advanceTimeBy(1_400L) + assertEquals(listOf(SleepTimerPopupDismissReason.Confirmation), dismissed) + } + + @Test + fun `a prompt shown again after a pick starts afresh, not on the last confirmation`() { + show() + compose.onNodeWithText("45m").performClick() + compose.mainClock.advanceTimeBy(3_200L) + assertEquals(listOf(SleepTimerPopupDismissReason.Confirmation), dismissed) + // The app hides it, as the repository does on the confirmation. + toggle(false) + compose.mainClock.advanceTimeBy(600L) + assertEquals(listOf(SleepTimerPopupDismissReason.Confirmation), dismissed) + // Shown again later (the next night, or the next play with the night window bypassed). + toggle(true) + compose.mainClock.advanceTimeBy(1_200L) + compose.onNodeWithText("Pausing in 45 min").assertDoesNotExist() + compose.onNodeWithText("Late night listening?").assertExists() + val times = compose.onNodeWithText("30m").getUnclippedBoundsInRoot() + assertTrue("The choices are laid out, not folded away", (times.bottom - times.top).value > 0f) + compose.onNodeWithText("1h").performClick() + assertEquals(listOf(45, 60), picked) + compose.mainClock.advanceTimeBy(1_800L) + compose.onNodeWithText("Pausing in 1 hr").assertExists() + } + + @Test + fun `hidden from outside while confirming, the confirmation is reported once`() { + show() + compose.onNodeWithText("2h").performClick() + compose.mainClock.advanceTimeBy(500L) + toggle(false) + compose.mainClock.advanceTimeBy(4_000L) + assertEquals(listOf(SleepTimerPopupDismissReason.Confirmation), dismissed) + } + + @Test + fun `Dismiss after a pick only ends the confirmation, reported once`() { + show() + compose.onNodeWithText("30m").performClick() + compose.mainClock.advanceTimeBy(400L) + compose.onNodeWithContentDescription("Dismiss").performClick() + assertEquals(listOf(SleepTimerPopupDismissReason.Confirmation), dismissed) + toggle(false) + compose.mainClock.advanceTimeBy(4_000L) + assertEquals(listOf(30), picked) + assertEquals(listOf(SleepTimerPopupDismissReason.Confirmation), dismissed) + } + + @Test + fun `a prompt dismissed just before it would time out does not also time out`() { + show() + // 7.9 seconds into the 8-second auto-hide. + compose.mainClock.advanceTimeBy(6_700L) + compose.onNodeWithContentDescription("Dismiss").performClick() + toggle(false) + compose.mainClock.advanceTimeBy(1_000L) + assertEquals(listOf(SleepTimerPopupDismissReason.Manual), dismissed) + } + + @Test + fun `after a pick screen readers reach the answer, not the faded question or the old times`() { + show() + compose.onNodeWithText("2h").performClick() + compose.mainClock.advanceTimeBy(100L) + compose.onNodeWithText("Late night listening?").assertDoesNotExist() + compose.onNodeWithText("30m").assertDoesNotExist() + compose.onNodeWithText("Pausing in 2 hr").assertExists() + } + + @Test + fun `with system animations off every step is at its final state at once`() { + setAnimatorScale(0f) + try { + show() + val open = promptHeight() + compose.onNodeWithText("45m").performClick() + // One frame: the choices are already folded away and the answer is there. + compose.mainClock.advanceTimeBy(16L) + compose.onNodeWithText("Pausing in 45 min").assertExists() + assertTrue("The choices fold away at once", promptHeight() < open - 80f) + assertEquals(listOf(45), picked) + } finally { + setAnimatorScale(1f) + } + } + + @Test + fun `whole hours and the end of the episode read as such`() { + show() + compose.onNodeWithText("End of episode").performClick() + compose.mainClock.advanceTimeBy(1_800L) + compose.onNodeWithText("Pausing after this episode").assertExists() + assertEquals(listOf(999), picked) + } + + @Test + fun `Dismiss and leaving it alone close the prompt for their own reasons`() { + show() + compose.onNodeWithContentDescription("Dismiss").performClick() + assertEquals(listOf(SleepTimerPopupDismissReason.Manual), dismissed) + dismissed.clear() + compose.mainClock.advanceTimeBy(8_000L) + assertEquals(listOf(SleepTimerPopupDismissReason.Timeout), dismissed) + assertTrue(picked.isEmpty()) + } + + @Test + fun `the confirmation survives recreation without setting the timer again`() { + val restoration = StateRestorationTester(compose) + show(restoration) + compose.onNodeWithText("1h").performClick() + restoration.emulateSavedInstanceStateRestore() + compose.mainClock.advanceTimeBy(1_800L) + compose.onNodeWithText("Pausing in 1 hr").assertExists() + assertEquals(listOf(60), picked) + } + + @Test + fun `the prompt fills a phone and stays card-sized on a wide window, through window changes both ways`() { + show() + assertWidth(360.dp - 32.dp) + compose.onNodeWithText("2h").performClick() + window = 840.dp + compose.mainClock.advanceTimeBy(400L) + assertWidth(420.dp) + window = 360.dp + compose.mainClock.advanceTimeBy(1_600L) + assertWidth(360.dp - 32.dp) + compose.onNodeWithText("Pausing in 2 hr").assertExists() + assertEquals(listOf(120), picked) + } + + /** Shows or hides the prompt as the app does, letting Compose take the change in before time moves on. */ + private fun toggle(value: Boolean) { + shown = value + compose.waitForIdle() + } + + /** The system animator duration scale, as Settings sets it: 0 turns animations off. */ + private fun setAnimatorScale(scale: Float) { + ReflectionHelpers.callStaticMethod( + ValueAnimator::class.java, + "setDurationScale", + ReflectionHelpers.ClassParameter.from(Float::class.javaPrimitiveType, scale), + ) + } + + private fun promptHeight(): Float { + val bounds = compose.onNodeWithTag(PROMPT).getUnclippedBoundsInRoot() + return (bounds.bottom - bounds.top).value + } + + private fun assertWidth(expected: Dp) { + val bounds = compose.onNodeWithTag(PROMPT).getUnclippedBoundsInRoot() + assertEquals(expected.value, (bounds.right - bounds.left).value, 1f) + } + + private companion object { + const val PROMPT = "sleepPrompt" + } +} diff --git a/release-notes/upcoming.md b/release-notes/upcoming.md index b3b64831b..e6d6c263d 100644 --- a/release-notes/upcoming.md +++ b/release-notes/upcoming.md @@ -30,6 +30,7 @@ Announcement title: - Conspiracy Theories' show page is now an evidence board, with the latest episodes pinned up and tied together with red yarn, on white paper by day and black by night. - Show pages in a show's own style carry a "The best of boxlore" seal in place of the "Made for" tag. Tap it to find out what the page is. +- The late-night sleep timer prompt has a new look in your theme's colours: a night sky with a turning moon, and once you pick a time it shows when playback will pause. ## Fixed