diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/navigation/Routes.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/navigation/Routes.kt index a0f0cd0..c3f1030 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/navigation/Routes.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/navigation/Routes.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/animation/AnimationTestingScreenState.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/animation/AnimationTestingScreenState.kt new file mode 100644 index 0000000..c66dc4f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/animation/AnimationTestingScreenState.kt @@ -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.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 + } +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/animation/TaskAnimations.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/animation/TaskAnimations.kt new file mode 100644 index 0000000..0853440 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/animation/TaskAnimations.kt @@ -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`. + */ +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` at composition time. */ + fun toFloatSpec(): AnimationSpec + } + + /** + * 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 = 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 = 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 = 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) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/dev/AnimationTestingScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/dev/AnimationTestingScreen.kt new file mode 100644 index 0000000..fd210d9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/dev/AnimationTestingScreen.kt @@ -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(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" + } +} diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/animation/AnimationTestingScreenStateTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/animation/AnimationTestingScreenStateTest.kt new file mode 100644 index 0000000..ae01e96 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/animation/AnimationTestingScreenStateTest.kt @@ -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) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/animation/TaskAnimationsTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/animation/TaskAnimationsTest.kt new file mode 100644 index 0000000..340410f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/animation/TaskAnimationsTest.kt @@ -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)") + } +}