Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions core/designsystem/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading