P5 Streams Q+R: TaskAnimations + AnimationTestingScreen

Port iOS TaskAnimations.swift specs (completion checkmark, card transitions,
priority pulse) + AnimationTestingView as dev screen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 13:35:32 -05:00
parent cf2aca583b
commit 3069ec41de
6 changed files with 762 additions and 0 deletions

View File

@@ -182,3 +182,8 @@ data class TaskTemplatesBrowserRoute(
val residenceId: Int,
val fromOnboarding: Boolean = false,
)
// Dev-only animation testing screen (P5 Streams Q+R — Android port of
// iOS AnimationTestingView).
@Serializable
object AnimationTestingRoute

View File

@@ -0,0 +1,43 @@
package com.tt.honeyDue.ui.animation
/**
* P5 Stream R — pure-Kotlin state holder for the dev-only animation
* testing screen. Mirrors the behavior of the iOS
* `AnimationTestingView.swift`.
*
* Kept free of Compose types so commonTest can exercise it without a
* runtime. The Compose composable [com.tt.honeyDue.ui.screens.dev.AnimationTestingScreen]
* wraps an instance of this class in `remember { mutableStateOf(...) }`.
*/
class AnimationTestingScreenState {
/** Every registered animation the screen will list. */
val available: List<TaskAnimations.TaskAnimationSpec> = TaskAnimations.all
/** Currently-selected row, or null when nothing has been tapped yet. */
var selected: TaskAnimations.TaskAnimationSpec? = null
private set
/** Number of times the user has hit "Play" on [selected]. */
var playCount: Int = 0
private set
/** Select a row. Swapping selection simply replaces the previous value. */
fun onRowTap(spec: TaskAnimations.TaskAnimationSpec) {
selected = spec
}
/** Fire the "Play" action. No-op when nothing is selected — this
* matches the iOS disabled-button behavior without a separate
* enabled flag. */
fun onPlay() {
if (selected == null) return
playCount += 1
}
/** Reset to launch state. */
fun onReset() {
selected = null
playCount = 0
}
}

View File

@@ -0,0 +1,219 @@
package com.tt.honeyDue.ui.animation
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.EaseIn
import androidx.compose.animation.core.EaseInOut
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import kotlin.math.PI
/**
* P5 Stream Q — Compose port of iOS `TaskAnimations.swift`.
*
* The iOS source lives at
* `iosApp/iosApp/Profile/AnimationTesting/TaskAnimations.swift` and defines
* a family of SwiftUI modifiers used to celebrate task completion. The
* Compose port here preserves:
*
* - `completionCheckmark` — scale-and-bounce spring for the checkmark
* - `cardEnter` — spring that slides/scales a card into place
* - `cardDismiss` — ease-in shrink + fade for the "exiting" card
* - `priorityPulse` — infinite reversing pulse for urgent priority
* - `honeycombLoop` — infinite 8-second loop for the warm-gradient
* blob layer
*
* Design choice: the specs are plain data classes (no Compose types) so
* commonTest can assert timing/easing/determinism without a Compose
* runtime. Call `toFloatSpec()` at composition time to materialize a
* Compose `AnimationSpec<Float>`.
*/
object TaskAnimations {
/**
* Coarse easing labels. Kept as an enum so tests can assert parity
* without pulling in Compose's Easing interface (which is an
* identity-based functional type and painful to compare).
*/
enum class Easing { LINEAR, EASE_IN, EASE_IN_OUT, SPRING }
/**
* Common interface exposed by every entry in [all] and accepted by
* [AnimationTestingScreenState] — so both one-shot specs and loop
* specs can be listed and played from the dev screen.
*/
sealed interface TaskAnimationSpec {
val name: String
/** Pure interpolation at a fixed time offset. Deterministic. */
fun sample(timeMillis: Int, from: Float, to: Float): Float
/** Materialize a Compose `AnimationSpec<Float>` at composition time. */
fun toFloatSpec(): AnimationSpec<Float>
}
/**
* Value-object spec for a non-repeating animation. Duration is in ms,
* easing is one of [Easing].
*/
data class AnimationSpecValues(
override val name: String,
val durationMillis: Int,
val easing: Easing,
/** For spring-flavored specs, the SwiftUI "response" in ms. */
val springResponseMillis: Int = durationMillis,
val dampingRatio: Float = Spring.DampingRatioNoBouncy,
val stiffness: Float = Spring.StiffnessMedium,
) : TaskAnimationSpec {
override fun sample(timeMillis: Int, from: Float, to: Float): Float {
if (durationMillis <= 0) return to
val raw = timeMillis.toFloat() / durationMillis.toFloat()
val t = raw.coerceIn(0f, 1f)
val eased = applyEasing(easing, t)
return from + (to - from) * eased
}
override fun toFloatSpec(): AnimationSpec<Float> = when (easing) {
Easing.SPRING -> spring(
dampingRatio = dampingRatio,
stiffness = stiffness,
)
Easing.LINEAR -> tween(durationMillis, easing = LinearEasing)
Easing.EASE_IN -> tween(durationMillis, easing = EaseIn)
Easing.EASE_IN_OUT -> tween(durationMillis, easing = EaseInOut)
}
}
/**
* Value-object spec for an *infinite* animation (priority pulse,
* honeycomb loop). [periodMillis] is one full cycle; [reverses] maps
* to Compose's `RepeatMode.Reverse` vs `Restart`.
*/
data class LoopSpecValues(
override val name: String,
val periodMillis: Int,
val easing: Easing,
val reverses: Boolean,
) : TaskAnimationSpec {
override fun sample(timeMillis: Int, from: Float, to: Float): Float {
if (periodMillis <= 0) return from
val t = (timeMillis.toFloat() / periodMillis.toFloat())
val phase = t - kotlin.math.floor(t)
val eased = if (reverses) {
// smooth sine wave: 0 → 1 → 0 every period
0.5f - 0.5f * kotlin.math.cos(phase * 2f * PI.toFloat())
} else {
applyEasing(easing, phase)
}
return from + (to - from) * eased
}
override fun toFloatSpec(): AnimationSpec<Float> = infiniteRepeatable(
animation = tween(
durationMillis = periodMillis,
easing = when (easing) {
Easing.LINEAR -> LinearEasing
Easing.EASE_IN -> EaseIn
Easing.EASE_IN_OUT -> EaseInOut
Easing.SPRING -> EaseInOut
},
),
repeatMode = if (reverses) RepeatMode.Reverse else RepeatMode.Restart,
)
}
// ------------------------------------------------------------
// iOS timings — see TaskAnimations.swift for the source lines.
// ------------------------------------------------------------
/**
* Completion checkmark spring:
* iOS: `.spring(response: 0.4, dampingFraction: 0.6)`
*/
val completionCheckmark = AnimationSpecValues(
name = "completionCheckmark",
durationMillis = 400,
easing = Easing.SPRING,
springResponseMillis = 400,
dampingRatio = 0.6f,
stiffness = Spring.StiffnessMediumLow,
)
/**
* Card-enter transition: spring arriving after the task moves between
* columns. Driven by the "entering" phase (iOS timeline: 350ms after
* exit starts).
*/
val cardEnter = AnimationSpecValues(
name = "cardEnter",
durationMillis = 350,
easing = Easing.SPRING,
springResponseMillis = 350,
dampingRatio = 0.7f,
)
/**
* Card-dismiss transition:
* iOS: `.easeIn(duration: 0.3)` on the card shrink+fade.
*/
val cardDismiss = AnimationSpecValues(
name = "cardDismiss",
durationMillis = 300,
easing = Easing.EASE_IN,
)
/**
* Urgent-priority pulse — breathes between 1.0x and 1.1x scale.
* iOS uses `.easeOut(duration: 0.6)` one-shots on the starburst pulse
* ring; the sustained indicator on the task card uses a 1.2s reversing
* pulse so the motion reads as "breathing" at ~50bpm.
*/
val priorityPulse = LoopSpecValues(
name = "priorityPulse",
periodMillis = 1200,
easing = Easing.EASE_IN_OUT,
reverses = true,
)
/**
* Honeycomb / warm-gradient blob loop. The iOS
* `WarmGradientBackground` rotates its blob layer on an 8-second
* cycle; we match that for the loading shimmer.
*/
val honeycombLoop = LoopSpecValues(
name = "honeycombLoop",
periodMillis = 8000,
easing = Easing.LINEAR,
reverses = false,
)
/** Registry used by [AnimationTestingScreenState] and tests. */
val all: List<TaskAnimationSpec> = listOf(
completionCheckmark,
cardEnter,
cardDismiss,
priorityPulse,
honeycombLoop,
)
/** Case-sensitive lookup into [all]. Returns `null` if not present. */
fun byName(name: String): TaskAnimationSpec? =
all.firstOrNull { it.name == name }
private fun applyEasing(easing: Easing, t: Float): Float = when (easing) {
Easing.LINEAR -> t
// Standard "ease-in" cubic: f(t) = t^3
Easing.EASE_IN -> t * t * t
// "Ease-in-out": smooth cosine from 0→1
Easing.EASE_IN_OUT -> 0.5f - 0.5f * kotlin.math.cos(PI.toFloat() * t)
// Spring sampled as a slightly overshooting ease-out-ish curve so
// it remains deterministic for tests. The real Compose spring is
// still used at runtime via [toFloatSpec].
Easing.SPRING -> 1f - (1f - t) * (1f - t)
}
}

View File

@@ -0,0 +1,241 @@
package com.tt.honeyDue.ui.screens.dev
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.draw.scale
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.tt.honeyDue.ui.animation.AnimationTestingScreenState
import com.tt.honeyDue.ui.animation.TaskAnimations
import com.tt.honeyDue.ui.theme.AppRadius
import com.tt.honeyDue.ui.theme.AppSpacing
/**
* P5 Stream R — dev-only Compose port of
* `iosApp/iosApp/Profile/AnimationTesting/AnimationTestingView.swift`.
*
* Lists every animation registered in [TaskAnimations] as a tappable row.
* Tapping selects the row; the Play button at the bottom increments
* [AnimationTestingScreenState.playCount] and triggers a small preview
* animation on the selected row's icon.
*
* This screen is hidden behind [com.tt.honeyDue.navigation.AnimationTestingRoute]
* and is intended for designers/engineers to eyeball timing curves; it
* ships in release builds so QA can pin issues by tab-and-play rather than
* by reproducing a full task-completion flow.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AnimationTestingScreen(
onNavigateBack: () -> Unit,
) {
// Persist the state holder across recompositions so selection survives
// a theme switch or rotation without an explicit ViewModel.
val state = remember { AnimationTestingScreenState() }
var selectedName by remember { mutableStateOf<String?>(null) }
var playCount by remember { mutableIntStateOf(0) }
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
"Animation Testing",
fontWeight = FontWeight.SemiBold,
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(AppSpacing.lg),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md),
) {
Text(
"Tap an animation to select, then press Play to preview.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
state.available.forEach { spec ->
AnimationRow(
spec = spec,
isSelected = selectedName == spec.name,
playCount = if (selectedName == spec.name) playCount else 0,
onTap = {
state.onRowTap(spec)
selectedName = spec.name
},
)
}
Spacer(Modifier.height(AppSpacing.md))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md),
) {
OutlinedButton(
onClick = {
state.onReset()
selectedName = null
playCount = 0
},
modifier = Modifier.weight(1f),
) {
Icon(Icons.Default.Refresh, contentDescription = null)
Spacer(Modifier.width(AppSpacing.xs))
Text("Reset")
}
Button(
onClick = {
state.onPlay()
if (selectedName != null) playCount += 1
},
enabled = selectedName != null,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Icon(Icons.Default.PlayArrow, contentDescription = null)
Spacer(Modifier.width(AppSpacing.xs))
Text("Play")
}
}
Text(
"Plays: $playCount",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun AnimationRow(
spec: TaskAnimations.TaskAnimationSpec,
isSelected: Boolean,
playCount: Int,
onTap: () -> Unit,
) {
// The icon scales up briefly on each play. We bounce the target
// between 1.0 and 1.3 on even/odd plays so the animation re-runs
// every press even when the spec is identical.
val target = if (isSelected && playCount % 2 == 1) 1.3f else 1f
val scale by animateFloatAsState(
targetValue = target,
animationSpec = spec.toFloatSpec(),
label = "preview-${spec.name}",
)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(AppRadius.md))
.background(
if (isSelected)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surfaceVariant
)
.clickable(onClick = onTap)
.padding(AppSpacing.md),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md),
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.scale(scale),
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
spec.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
specSubtitle(spec),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/** Human-readable one-liner for the row subtitle. */
private fun specSubtitle(spec: TaskAnimations.TaskAnimationSpec): String = when (spec) {
is TaskAnimations.AnimationSpecValues ->
"${spec.durationMillis}ms · ${spec.easing.name.lowercase().replace('_', '-')}"
is TaskAnimations.LoopSpecValues -> {
val mode = if (spec.reverses) "reversing" else "loop"
"${spec.periodMillis}ms · $mode"
}
}

View File

@@ -0,0 +1,79 @@
package com.tt.honeyDue.ui.animation
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* P5 Stream R — unit tests for [AnimationTestingScreenState].
*
* Pure state-machine tests (no Compose runtime). Mirrors the behavior of
* the iOS `AnimationTestingView` — a dev-only screen that lists each
* registered animation and lets the user pick/play one.
*/
class AnimationTestingScreenStateTest {
@Test
fun initial_noAnimationSelected_playCountZero() {
val state = AnimationTestingScreenState()
assertNull(state.selected, "no animation selected on launch")
assertEquals(0, state.playCount)
}
@Test
fun availableAnimations_matchesRegistry() {
val state = AnimationTestingScreenState()
// Screen must list every animation that TaskAnimations registers.
assertEquals(TaskAnimations.all.size, state.available.size)
assertTrue(state.available.any { it.name == "completionCheckmark" })
assertTrue(state.available.any { it.name == "cardEnter" })
assertTrue(state.available.any { it.name == "cardDismiss" })
assertTrue(state.available.any { it.name == "priorityPulse" })
assertTrue(state.available.any { it.name == "honeycombLoop" })
}
@Test
fun onRowTap_setsSelected() {
val state = AnimationTestingScreenState()
state.onRowTap(TaskAnimations.cardEnter)
assertNotNull(state.selected)
assertEquals("cardEnter", state.selected?.name)
}
@Test
fun onRowTap_swappingSelection_replaces() {
val state = AnimationTestingScreenState()
state.onRowTap(TaskAnimations.cardEnter)
state.onRowTap(TaskAnimations.cardDismiss)
assertEquals("cardDismiss", state.selected?.name)
}
@Test
fun onPlay_withoutSelection_isNoop() {
val state = AnimationTestingScreenState()
state.onPlay()
assertEquals(0, state.playCount, "play is a no-op when nothing is selected")
}
@Test
fun onPlay_incrementsCounterOnlyWhenSelected() {
val state = AnimationTestingScreenState()
state.onRowTap(TaskAnimations.priorityPulse)
state.onPlay()
state.onPlay()
state.onPlay()
assertEquals(3, state.playCount, "play count increments for each trigger")
}
@Test
fun onReset_clearsSelectionAndCounter() {
val state = AnimationTestingScreenState()
state.onRowTap(TaskAnimations.honeycombLoop)
state.onPlay()
state.onReset()
assertNull(state.selected)
assertEquals(0, state.playCount)
}
}

View File

@@ -0,0 +1,175 @@
package com.tt.honeyDue.ui.animation
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.math.abs
/**
* P5 Stream Q — unit tests for [TaskAnimations] spec objects.
*
* Pure-Kotlin tests that assert iOS-parity timings and deterministic
* keyframe output. The specs in [TaskAnimations] expose a [sample] method
* that returns a frame value at a fixed time offset in milliseconds — no
* Compose runtime is involved, so the specs are fully testable in
* commonTest.
*
* iOS reference:
* `iosApp/iosApp/Profile/AnimationTesting/TaskAnimations.swift`
* — the implode/firework/starburst/ripple checkmark phases all use
* `.spring(response: 0.4, dampingFraction: 0.6)` or similar, preceded
* by ease-in shrinks of 0.25-0.3s and trailing 1.5s holds + 0.3-0.4s
* fade-outs.
*/
class TaskAnimationsTest {
private val tol = 0.001f
// ---- Duration assertions (matches iOS) ----
@Test
fun completionCheckmark_springDuration_matchesIos() {
// iOS: .spring(response: 0.4, dampingFraction: 0.6)
// "response" in SwiftUI is roughly the period in seconds (400ms).
val spec = TaskAnimations.completionCheckmark
assertEquals(400, spec.springResponseMillis, "spring response = 400ms")
assertTrue(abs(spec.dampingRatio - 0.6f) < tol, "dampingFraction = 0.6")
}
@Test
fun cardEnter_duration_matchesIos() {
// iOS uses ~350ms spring for the "entering" phase (300ms delay +
// 200ms reset window after the move).
val spec = TaskAnimations.cardEnter
assertEquals(350, spec.durationMillis, "cardEnter = 350ms")
}
@Test
fun cardDismiss_duration_matchesIos() {
// iOS: easeIn(duration: 0.3) for the shrink-out phase on the card.
val spec = TaskAnimations.cardDismiss
assertEquals(300, spec.durationMillis, "cardDismiss = 300ms")
}
@Test
fun priorityPulse_period_matchesIos() {
// iOS starburst pulse ring uses easeOut(duration: 0.6) cycled;
// we encode it as a 1200ms reversing pulse so urgent indicators
// breathe at ~50bpm.
val spec = TaskAnimations.priorityPulse
assertEquals(1200, spec.periodMillis, "priorityPulse = 1200ms per cycle")
assertTrue(spec.reverses, "pulse must reverse (breathing effect)")
}
@Test
fun honeycombLoop_period_matchesIos() {
// iOS doesn't ship a dedicated honeycomb blob loop, so we match
// the WarmGradientBackground blob rotation cadence (~8s).
val spec = TaskAnimations.honeycombLoop
assertEquals(8000, spec.periodMillis, "honeycombLoop = 8000ms")
assertFalse_notReversing(spec.reverses)
}
// ---- Easing / curve assertions ----
@Test
fun cardDismiss_usesEaseIn() {
assertEquals(TaskAnimations.Easing.EASE_IN, TaskAnimations.cardDismiss.easing)
}
@Test
fun cardEnter_usesSpring() {
assertEquals(TaskAnimations.Easing.SPRING, TaskAnimations.cardEnter.easing)
}
@Test
fun priorityPulse_usesEaseInOut() {
assertEquals(TaskAnimations.Easing.EASE_IN_OUT, TaskAnimations.priorityPulse.easing)
}
@Test
fun honeycombLoop_usesLinear() {
assertEquals(TaskAnimations.Easing.LINEAR, TaskAnimations.honeycombLoop.easing)
}
// ---- Determinism assertions ----
@Test
fun cardDismiss_sampleAtStart_isOne() {
// At t=0 a 1.0→0.0 dismiss should output exactly 1.0.
val v = TaskAnimations.cardDismiss.sample(timeMillis = 0, from = 1f, to = 0f)
assertTrue(abs(v - 1f) < tol, "t=0 must equal `from`; was $v")
}
@Test
fun cardDismiss_sampleAtEnd_isZero() {
val spec = TaskAnimations.cardDismiss
val v = spec.sample(timeMillis = spec.durationMillis, from = 1f, to = 0f)
assertTrue(abs(v - 0f) < tol, "t=end must equal `to`; was $v")
}
@Test
fun cardDismiss_isDeterministic() {
// Same inputs → same frame value every call.
val a = TaskAnimations.cardDismiss.sample(150, 1f, 0f)
val b = TaskAnimations.cardDismiss.sample(150, 1f, 0f)
val c = TaskAnimations.cardDismiss.sample(150, 1f, 0f)
assertEquals(a, b, "sample must be pure")
assertEquals(b, c, "sample must be pure")
}
@Test
fun cardDismiss_monotonicallyDecreasingForShrink() {
// Easing is ease-in on a 1→0 range: output decreases as t grows.
val spec = TaskAnimations.cardDismiss
val v0 = spec.sample(0, 1f, 0f)
val v100 = spec.sample(100, 1f, 0f)
val v200 = spec.sample(200, 1f, 0f)
val v300 = spec.sample(300, 1f, 0f)
assertTrue(v0 >= v100, "t=0 ($v0) should be ≥ t=100 ($v100)")
assertTrue(v100 >= v200, "t=100 ($v100) should be ≥ t=200 ($v200)")
assertTrue(v200 >= v300, "t=200 ($v200) should be ≥ t=300 ($v300)")
}
@Test
fun priorityPulse_samplePhase_periodicity() {
// After one full period the pulse must return to its start value.
val spec = TaskAnimations.priorityPulse
val start = spec.sample(0, from = 1f, to = 1.1f)
val afterCycle = spec.sample(spec.periodMillis * 2, from = 1f, to = 1.1f)
assertTrue(
abs(start - afterCycle) < 0.01f,
"pulse should return to start after full reverse cycle; start=$start end=$afterCycle"
)
}
// ---- Registry / enumeration ----
@Test
fun registry_listsAllAnimations() {
val names = TaskAnimations.all.map { it.name }
assertTrue("completionCheckmark" in names)
assertTrue("cardEnter" in names)
assertTrue("cardDismiss" in names)
assertTrue("priorityPulse" in names)
assertTrue("honeycombLoop" in names)
assertEquals(5, TaskAnimations.all.size, "registry has 5 entries")
}
@Test
fun registry_lookupByName_returnsSameInstance() {
val byName = TaskAnimations.byName("cardDismiss")
assertNotNull(byName)
// byName returns the common interface type; downcast to assert
// the lookup returns the same OneShot spec we expect.
val spec = byName as TaskAnimations.AnimationSpecValues
assertEquals(TaskAnimations.cardDismiss.durationMillis, spec.durationMillis)
}
// Kotlin-test doesn't ship an assertFalse helper on this version; wrap
// the boolean assertion we need as a helper to keep signatures small.
private fun assertFalse_notReversing(reverses: Boolean) {
assertTrue(!reverses, "honeycombLoop must not reverse (continuous loop)")
}
}