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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.io.IOException
Expand All @@ -45,24 +46,31 @@ class SettingsRepositoryImpl @Inject constructor(
val STEP_MINUTES = intPreferencesKey("step_minutes")
val PRESET_MINUTES = intPreferencesKey("preset_minutes")
val LAUNCH_ANIMATION_ENABLED = booleanPreferencesKey("launch_animation_enabled")
val LAUNCH_ANIMATION_SEEDED = booleanPreferencesKey("launch_animation_seeded")
}

// Einmaliger Init-Scope. IO-Dispatcher ist angemessen für DataStore-Writes,
// SupervisorJob verhindert dass eine Child-Exception weitere Writes stoppt.
// Einmaliger Init-Scope fürs Seeding. IO-Dispatcher ist angemessen für DataStore-Zugriffe.
private val initScope = CoroutineScope(Dispatchers.IO + SupervisorJob())

init {
// Seed-on-first-install: ist der „seeded"-Flag nicht gesetzt, wird das
// launchAnimationEnabled-Feld einmalig basierend auf der System-Reduce-Motion-
// Präferenz persistiert. Danach gewinnen User-Overrides. Spätere System-Änderungen
// werden bewusst nicht reflektiert (siehe Spec, Out-of-Scope).
// Seed-on-first-install: solange der Key noch nie geschrieben wurde, wird
// launchAnimationEnabled einmalig aus der System-Reduce-Motion-Präferenz
// abgeleitet. Key-Absenz statt separatem "seeded"-Flag: ein User-Toggle, das
// zuerst landet, kann so nie überschrieben werden. Der Read vor dem edit spart
// die Write-Transaktion bei jedem weiteren Prozess-Start. Spätere System-
// Änderungen werden bewusst nicht reflektiert (siehe Spec, Out-of-Scope).
initScope.launch {
dataStore.edit { prefs ->
if (prefs[LAUNCH_ANIMATION_SEEDED] != true) {
prefs[LAUNCH_ANIMATION_ENABLED] = !isSystemReduceMotionEnabled(context)
prefs[LAUNCH_ANIMATION_SEEDED] = true
try {
if (LAUNCH_ANIMATION_ENABLED !in dataStore.data.first()) {
dataStore.edit { prefs ->
if (LAUNCH_ANIMATION_ENABLED !in prefs) {
prefs[LAUNCH_ANIMATION_ENABLED] = !isSystemReduceMotionEnabled(context)
}
}
}
} catch (_: IOException) {
// Seeding ist best-effort: ohne Seed greift der UserSettings-Default.
// Ein I/O-Fehler darf den Prozess nicht crashen (Read-Pfad unten
// behandelt IOException genauso).
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Play-Button Launch-Animation Implementation Plan

> **Hinweis (2026-07-07):** Historischer Implementierungsplan — die finale Implementierung
> weicht in Details ab (u.a. bleibt das Play-Icon im `PlayButton` und wird während des
> Fluges nur per Alpha versteckt; die Phasen schalten durch Abwarten der Animationen
> weiter statt per wall-clock `delay()`; das Seeding nutzt kein separates "seeded"-Flag).
> Maßgeblich ist der Code.

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Eine Rocket-Launch-Animation auf den Play-Button packen: Icon dreht sich Richtung Dial, fliegt zum Dial-Zentrum, schlägt ein, Knob pulsiert und Shockwave rippelt — gesteuert durch ein neues Settings-Toggle und gegated durch Androids System-Reduce-Motion.
Expand All @@ -8,7 +14,7 @@

**Tech Stack:** Kotlin, Jetpack Compose, `androidx.compose.animation.core.Animatable`, `Modifier.onGloballyPositioned`, DataStore Preferences, Hilt.

**Spec reference:** `docs/superpowers/specs/2026-04-20-launch-animation-design.md`
**Spec reference:** `docs/plans/2026-04-20-launch-animation-design.md`

**Testing approach:** Dieses Projekt hat keine Tests — weder Unit- noch Instrumentation-Tests. Jeder Task endet mit **manueller Verifikation** (Build + ggf. visuelle Prüfung auf einem Debug-Build) und einem optionalen Commit. Der Executor entscheidet pro Task, ob committet wird; CLAUDE.md schreibt Commits nicht generell vor, die Anweisungen hier sind Vorschläge.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,6 @@ private fun TimerContent(
val launchController = rememberLaunchAnimationController()
var buttonCenter by remember { mutableStateOf(Offset.Zero) }
var dialCenter by remember { mutableStateOf(Offset.Zero) }
val animationEnabled = settings.launchAnimationEnabled &&
!isSystemReduceMotionEnabled(context)

// Snap zurück auf Idle, wenn die App in den Hintergrund geht.
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
Expand Down Expand Up @@ -323,7 +321,7 @@ private fun TimerContent(
hapticEnabled = settings.hapticFeedbackEnabled,
onMinutesChanged = viewModel::setMinutes,
onMinutesCommitted = viewModel::commitMinutes,
impactPulse = launchController.impactPulse.value,
impactPulse = { launchController.impactPulse.value },
modifier = Modifier.fillMaxSize(),
)

Expand Down Expand Up @@ -361,28 +359,26 @@ private fun TimerContent(
val launchPhase = launchController.phase
// Der Button zeigt die Running-Visuals (Stop-Icon + Shape-Morph) erst NACHDEM
// die Launch-Animation komplett durchgelaufen ist. Während der Flug läuft,
// soll der Button leer bleiben — sonst sieht es aus als käme ein zweites
// Icon hinten aus dem Button raus. Sobald der Controller wieder auf Idle
// steht (nach der Impact-Phase) und der Timer tatsächlich läuft, wechselt
// der Button via Crossfade auf Stop.
// versteckt der Button sein Play-Icon (hidePlayIcon) — sonst sieht es aus
// als käme ein zweites Icon hinten aus dem Button raus. Sobald der Controller
// wieder auf Idle steht (nach der Impact-Phase) und der Timer tatsächlich
// läuft, wechselt der Button via Crossfade auf Stop.
val playButtonShowsRunning = isRunning && launchPhase == LaunchPhase.Idle
// Ziel-Rotation des Icons relativ zur X-Achse (Icon zeigt standardmäßig rechts).
val targetIconAngleDeg = remember(buttonCenter, dialCenter) {
if (buttonCenter == Offset.Zero || dialCenter == Offset.Zero) 0f
else {
val dx = dialCenter.x - buttonCenter.x
val dy = dialCenter.y - buttonCenter.y
Math.toDegrees(atan2(dy, dx).toDouble()).toFloat()
}
}

ActionRow(
isRunning = isRunning,
playButtonShowsRunning = playButtonShowsRunning,
// Semantik folgt der echten Aktion: während der Animation stoppt ein
// Tap den bereits laufenden Timer — also "Stop" ansagen, nicht den
// visuellen Zustand.
playButtonDescribesRunning = isRunning || launchPhase != LaunchPhase.Idle,
hidePlayIcon = launchPhase != LaunchPhase.Idle,
hapticEnabled = settings.hapticFeedbackEnabled,
iconRotation = animatedAngle,
onToggle = {
val animating = launchPhase != LaunchPhase.Idle
// Live vom Controller lesen, nicht den Composition-Snapshot:
// launch() setzt phase synchron, damit ist auch ein zweiter Tap
// im selben Frame korrekt als "animiert bereits" erkannt.
val animating = launchController.phase != LaunchPhase.Idle
if (isRunning || animating) {
viewModel.stopTimer()
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
Expand All @@ -394,11 +390,22 @@ private fun TimerContent(
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
viewModel.startTimer()
if (animationEnabled &&
// Gate erst am Tap auswerten: kein Settings.Global-Read pro
// Recomposition, und Laufzeit-Änderungen des System-Settings
// werden mitgenommen.
if (settings.launchAnimationEnabled &&
!isSystemReduceMotionEnabled(context) &&
buttonCenter != Offset.Zero &&
dialCenter != Offset.Zero
) {
launchController.launch(targetIconAngleDeg)
// Ziel-Rotation relativ zur X-Achse (Icon zeigt standardmäßig rechts).
val dx = dialCenter.x - buttonCenter.x
val dy = dialCenter.y - buttonCenter.y
launchController.launch(
startIconRotationDeg = animatedAngle,
targetIconRotationDeg =
Math.toDegrees(atan2(dy, dx).toDouble()).toFloat(),
)
}
}
},
Expand Down Expand Up @@ -432,7 +439,7 @@ private fun TimerContent(
} else {
dialState.totalMinutes < MAX_TIMER_MINUTES
},
buttonScale = launchController.buttonScale.value,
buttonScale = { launchController.buttonScale.value },
onButtonPositioned = { buttonCenter = it },
)

Expand Down Expand Up @@ -474,11 +481,8 @@ private fun TimerContent(

LaunchOverlay(
controller = launchController,
isRunning = isRunning,
buttonCenter = buttonCenter,
dialCenter = dialCenter,
iconTint = appTheme().accentInk,
glowColor = appTheme().accent,
)
}
}
Expand Down Expand Up @@ -546,16 +550,17 @@ private fun HomeTopBar(

@Composable
private fun ActionRow(
isRunning: Boolean,
playButtonShowsRunning: Boolean,
playButtonDescribesRunning: Boolean,
hidePlayIcon: Boolean,
hapticEnabled: Boolean,
iconRotation: Float,
onToggle: () -> Unit,
onMinusStep: () -> Unit,
onPlusStep: () -> Unit,
isMinusEnabled: Boolean,
isPlusEnabled: Boolean,
buttonScale: Float,
buttonScale: () -> Float,
onButtonPositioned: (Offset) -> Unit,
) {
androidx.compose.foundation.layout.Row(
Expand All @@ -579,6 +584,8 @@ private fun ActionRow(
onClick = onToggle,
iconRotation = iconRotation,
buttonScale = buttonScale,
hidePlayIcon = hidePlayIcon,
describesRunning = playButtonDescribesRunning,
modifier = Modifier.onGloballyPositioned { coords ->
onButtonPositioned(coords.boundsInRoot().center)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ fun CircularDial(
onMinutesChanged: (Int) -> Unit,
onMinutesCommitted: (Int) -> Unit,
modifier: Modifier = Modifier,
impactPulse: Float = 0f,
// Lambda statt Float: der Wert wird erst im Draw-Pass gelesen, sodass jeder
// Impact-Frame nur den Canvas neu zeichnet statt den Dial zu recomposen.
impactPulse: () -> Float = { 0f },
) {
val theme = appTheme()
val view = LocalView.current
Expand Down Expand Up @@ -213,12 +215,13 @@ fun CircularDial(
dimmed = isRunning && !state.isDragging,
)

if (impactPulse > 0f) {
val pulse = impactPulse()
if (pulse > 0f) {
drawImpactEffects(
center = center,
ringRadius = radius,
strokeWidth = strokeWidth,
pulse = impactPulse.coerceIn(0f, 1f),
pulse = pulse.coerceIn(0f, 1f),
accent = theme.accent,
)
}
Expand Down
Loading
Loading