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"
}
}