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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user