diff --git a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepositoryImpl.kt b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepositoryImpl.kt index d673d25..ae0ec26 100644 --- a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepositoryImpl.kt +++ b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepositoryImpl.kt @@ -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 @@ -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). } } } diff --git a/docs/superpowers/specs/2026-04-20-launch-animation-design.md b/docs/plans/2026-04-20-launch-animation-design.md similarity index 100% rename from docs/superpowers/specs/2026-04-20-launch-animation-design.md rename to docs/plans/2026-04-20-launch-animation-design.md diff --git a/docs/superpowers/plans/2026-04-20-launch-animation.md b/docs/plans/2026-04-20-launch-animation.md similarity index 99% rename from docs/superpowers/plans/2026-04-20-launch-animation.md rename to docs/plans/2026-04-20-launch-animation.md index 0a40259..f035fde 100644 --- a/docs/superpowers/plans/2026-04-20-launch-animation.md +++ b/docs/plans/2026-04-20-launch-animation.md @@ -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. @@ -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. diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerScreen.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerScreen.kt index 2b3ac12..46a0502 100644 --- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerScreen.kt +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerScreen.kt @@ -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) { @@ -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(), ) @@ -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 && @@ -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(), + ) } } }, @@ -432,7 +439,7 @@ private fun TimerContent( } else { dialState.totalMinutes < MAX_TIMER_MINUTES }, - buttonScale = launchController.buttonScale.value, + buttonScale = { launchController.buttonScale.value }, onButtonPositioned = { buttonCenter = it }, ) @@ -474,11 +481,8 @@ private fun TimerContent( LaunchOverlay( controller = launchController, - isRunning = isRunning, buttonCenter = buttonCenter, dialCenter = dialCenter, - iconTint = appTheme().accentInk, - glowColor = appTheme().accent, ) } } @@ -546,8 +550,9 @@ private fun HomeTopBar( @Composable private fun ActionRow( - isRunning: Boolean, playButtonShowsRunning: Boolean, + playButtonDescribesRunning: Boolean, + hidePlayIcon: Boolean, hapticEnabled: Boolean, iconRotation: Float, onToggle: () -> Unit, @@ -555,7 +560,7 @@ private fun ActionRow( onPlusStep: () -> Unit, isMinusEnabled: Boolean, isPlusEnabled: Boolean, - buttonScale: Float, + buttonScale: () -> Float, onButtonPositioned: (Offset) -> Unit, ) { androidx.compose.foundation.layout.Row( @@ -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) }, diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/CircularDial.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/CircularDial.kt index 0171353..c08b345 100644 --- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/CircularDial.kt +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/CircularDial.kt @@ -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 @@ -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, ) } diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/LaunchAnimation.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/LaunchAnimation.kt index 0ec32ac..5b29e48 100644 --- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/LaunchAnimation.kt +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/LaunchAnimation.kt @@ -2,6 +2,7 @@ package dev.xitee.sleeptimer.feature.timer.timer.components import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Box @@ -19,14 +20,17 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.lerp import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp +import dev.xitee.sleeptimer.feature.timer.theme.appTheme import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.delay +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch enum class LaunchPhase { Idle, Crouch, Launch, Impact } @@ -57,77 +61,103 @@ class LaunchAnimationController(private val scope: CoroutineScope) { /** * Startet die Animations-Choreographie. Idempotent: wenn bereits nicht-Idle, no-op. - * @param targetIconRotationDeg Grad, auf den das Play-Icon während Crouch rotieren soll - * (meist der Winkel zum Dial-Zentrum; siehe TimerScreen). + * + * Die Phasen schalten weiter, sobald ihre Animationen fertig sind — bewusst kein + * wall-clock `delay()`: Animatable respektiert den System-Animator-Duration-Scale + * (MotionDurationScale), `delay()` nicht; bei z.B. 2x würde die State-Machine sonst + * mitten im Flug auf Impact springen. So leben die Dauern außerdem nur in den Specs. + * + * @param startIconRotationDeg Ausgangswinkel des Icons — die Orientierungs-Rotation + * des Button-Icons, damit die Übergabe vom `PlayButton` + * ans Overlay auch in Landscape nahtlos ist. + * @param targetIconRotationDeg Grad, auf den das Play-Icon während Crouch rotieren + * soll (der Winkel zum Dial-Zentrum; siehe TimerScreen). */ - fun launch(targetIconRotationDeg: Float) { + fun launch(startIconRotationDeg: Float, targetIconRotationDeg: Float) { if (phase != LaunchPhase.Idle) return + // Synchron setzen, nicht erst im Job: der Idle-Guard oben wirkt damit auch + // gegen einen zweiten Tap im selben Frame (Job-Start ist dispatched). + phase = LaunchPhase.Crouch + val previousJob = currentJob currentJob = scope.launch { - // Phase 1: Crouch (0–140ms) - phase = LaunchPhase.Crouch - val crouchEasing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f) - val crouchSpec = tween(140, easing = crouchEasing) - launch { buttonScale.animateTo(0.92f, crouchSpec) } - launch { iconRotationDeg.animateTo(targetIconRotationDeg, crouchSpec) } - launch { iconScale.animateTo(0.9f, crouchSpec) } - delay(140) - - // Phase 2: Launch (140–560ms, 420ms) — drei Segmente mit ease-in-out pro - // Segment. Waypoints: Travel 0 → 0.28 (30%) → 0.84 (80%) → 1.0 (100%), + // Ein evtl. noch ausstehender reset()-Snap darf nicht in diese Choreographie + // hineinschneiden — erst abwarten, dann von sauberen Idle-Werten starten. + previousJob?.cancelAndJoin() + snapToIdleValues() + iconRotationDeg.snapTo(startIconRotationDeg) + + // Phase 1: Crouch (140ms) + val crouchSpec = tween(140, easing = FastOutSlowInEasing) + coroutineScope { + launch { buttonScale.animateTo(0.92f, crouchSpec) } + launch { iconRotationDeg.animateTo(targetIconRotationDeg, crouchSpec) } + launch { iconScale.animateTo(0.9f, crouchSpec) } + } + + // Phase 2: Launch (420ms) — drei Segmente mit ease-in-out pro Segment. + // Waypoints: Travel 0 → 0.28 (30%) → 0.84 (80%) → 1.0 (100%), // Scale 0.9 → 1.1 → 0.9 → 0.5. An den Segmentgrenzen fällt die Velocity // nahezu auf Null (ease-out des einen, ease-in des nächsten) — das // erzeugt die charakteristischen „Hang"-Momente. phase = LaunchPhase.Launch - val launchEasing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f) - launch { buttonScale.animateTo(1f, tween(180)) } - launch { - iconTravel.animateTo(0.28f, tween(126, easing = launchEasing)) - iconTravel.animateTo(0.84f, tween(210, easing = launchEasing)) - iconTravel.animateTo(1f, tween(84, easing = launchEasing)) - } - launch { - iconScale.animateTo(1.1f, tween(126, easing = launchEasing)) - iconScale.animateTo(0.9f, tween(210, easing = launchEasing)) - iconScale.animateTo(0.5f, tween(84, easing = launchEasing)) + coroutineScope { + launch { buttonScale.animateTo(1f, tween(180)) } + launch { + iconTravel.animateTo(0.28f, tween(126, easing = FastOutSlowInEasing)) + iconTravel.animateTo(0.84f, tween(210, easing = FastOutSlowInEasing)) + iconTravel.animateTo(1f, tween(84, easing = FastOutSlowInEasing)) + } + launch { + iconScale.animateTo(1.1f, tween(126, easing = FastOutSlowInEasing)) + iconScale.animateTo(0.9f, tween(210, easing = FastOutSlowInEasing)) + iconScale.animateTo(0.5f, tween(84, easing = FastOutSlowInEasing)) + } } - delay(420) - // Phase 3: Impact (560–1160ms, 600ms). Länger als der Prototyp-Impact (260ms) + // Phase 3: Impact (600ms). Länger als der Prototyp-Impact (260ms) // weil wir die Shockwave-Expansion in derselben Pulse-Kurve steuern — dort // nutzt der Prototyp separate 900ms CSS-Animations die die Phase überdauern. phase = LaunchPhase.Impact - val impactEasing = CubicBezierEasing(0.12f, 0.85f, 0.3f, 1f) - val impactSpec = tween(600, easing = impactEasing) - launch { - buttonScale.animateTo( - 1.04f, - tween(130, easing = CubicBezierEasing(0.2f, 1.8f, 0.4f, 1f)), - ) - buttonScale.animateTo(1f, tween(170)) + val impactSpec = tween(600, easing = CubicBezierEasing(0.12f, 0.85f, 0.3f, 1f)) + coroutineScope { + launch { + buttonScale.animateTo( + 1.04f, + tween(130, easing = CubicBezierEasing(0.2f, 1.8f, 0.4f, 1f)), + ) + buttonScale.animateTo(1f, tween(170)) + } + launch { impactPulse.animateTo(1f, impactSpec) } } - launch { impactPulse.animateTo(1f, impactSpec) } - delay(600) // Zurück auf Idle (snap, nicht animiert, weil nächstes Frame den echten Running-State hat). - reset() + snapToIdleValues() + phase = LaunchPhase.Idle } } /** * Bricht eine laufende Animation ab und snapt alle Werte auf Idle-Defaults zurück. + * `phase` wird synchron zurückgesetzt; die Werte snappen in einem Job, den ein + * nachfolgendes launch() via cancelAndJoin abwartet — der Snap kann also nie in + * eine neue Choreographie hineinschneiden. */ fun reset() { - currentJob?.cancel() - currentJob = null - scope.launch { - buttonScale.snapTo(1f) - iconRotationDeg.snapTo(0f) - iconTravel.snapTo(0f) - iconScale.snapTo(1f) - impactPulse.snapTo(0f) + val previousJob = currentJob + currentJob = scope.launch { + previousJob?.cancelAndJoin() + snapToIdleValues() } phase = LaunchPhase.Idle } + + private suspend fun snapToIdleValues() { + buttonScale.snapTo(1f) + iconRotationDeg.snapTo(0f) + iconTravel.snapTo(0f) + iconScale.snapTo(1f) + impactPulse.snapTo(0f) + } } @Composable @@ -137,71 +167,59 @@ fun rememberLaunchAnimationController(): LaunchAnimationController { } /** - * Overlay, das das Play-Icon durchgängig rendert: im Idle sitzt es zentriert auf dem - * Button (dunkles `iconTint` auf Accent-Background), während des Fluges reist es zur - * Dial-Mitte mit einem weichen Accent-Glow zur Sichtbarkeit vor dem dunklen Hintergrund. - * Im Impact ist es „eingeschlagen" und nicht mehr sichtbar (Dial-Effekte übernehmen). - * Während der Timer läuft, übernimmt das Stop-Icon im `PlayButton` via Crossfade. + * Overlay, das das fliegende Play-Icon während Crouch + Launch rendert: es reist vom + * Button zur Dial-Mitte mit einem weichen Accent-Glow zur Sichtbarkeit vor dem dunklen + * Hintergrund. Im Idle (und während der Timer läuft) zeichnet der `PlayButton` sein + * Icon selbst — das Overlay übernimmt nur für die Flug-Phasen, in denen der Button + * sein Play-Icon per Alpha versteckt. Im Impact ist das Icon „eingeschlagen" und + * unsichtbar (Dial-Effekte übernehmen). + * + * Alle Animatable-Reads passieren in graphicsLayer-/Draw-Lambdas, damit jeder + * Animations-Frame nur ein Layer-Update bzw. Redraw ist, keine Recomposition. */ @Composable fun LaunchOverlay( controller: LaunchAnimationController, - isRunning: Boolean, buttonCenter: Offset, dialCenter: Offset, - iconTint: Color, - glowColor: Color, ) { val phase = controller.phase - val shouldRender = when (phase) { - LaunchPhase.Impact -> false - LaunchPhase.Idle -> !isRunning - LaunchPhase.Crouch, LaunchPhase.Launch -> true - } - if (!shouldRender) return - // Brauchen mindestens Button-Position; Dial-Position ist nur relevant sobald - // travel > 0 (wird in onToggle vor controller.launch() gegated). - if (buttonCenter == Offset.Zero) return - - val travel = controller.iconTravel.value - val iconScaleValue = controller.iconScale.value - // Icon fadet über die letzten 20% des Fluges aus — matched den Prototyp. - val alphaValue = - if (travel < 0.8f) 1f else (1f - (travel - 0.8f) / 0.2f).coerceIn(0f, 1f) - // Glow um das Icon herum — wächst mit Entfernung vom Button. - val glowAlpha = travel.coerceIn(0f, 1f) * 0.9f - - val currentX = buttonCenter.x + (dialCenter.x - buttonCenter.x) * travel - val currentY = buttonCenter.y + (dialCenter.y - buttonCenter.y) * travel + if (phase != LaunchPhase.Crouch && phase != LaunchPhase.Launch) return + // Beide Positionen sind vor controller.launch() gegated (siehe onToggle) — + // dieser Guard ist nur ein Sicherheitsnetz. + if (buttonCenter == Offset.Zero || dialCenter == Offset.Zero) return + + val theme = appTheme() // Trail: leuchtende Bahn vom Button bis zur aktuellen Icon-Position. Nur während // des Flugs gerendert. Das ist das wesentliche „Wow"-Element — ohne Trail wirkt // das fliegende Icon wie ein simples Schieben; mit Trail wird daraus ein Rocket- // Launch mit Abgasspur. Width konstant, Alpha-Kurve wie im Prototyp: steigt bis // ~40% Flugzeit auf Peak, fadet in den letzten 60% aus. - if (phase == LaunchPhase.Launch && travel > 0.02f && dialCenter != Offset.Zero) { - val trailAlpha = when { - travel < 0.4f -> travel / 0.4f - else -> (1f - (travel - 0.4f) / 0.6f).coerceAtLeast(0f) - } - if (trailAlpha > 0f) { - val trailHead = Offset(currentX, currentY) - Canvas(modifier = Modifier.fillMaxSize()) { - drawLine( - brush = Brush.linearGradient( - 0f to Color.Transparent, - 0.25f to glowColor.copy(alpha = 0.35f * trailAlpha), - 0.75f to glowColor.copy(alpha = 1f * trailAlpha), - 1f to Color.White.copy(alpha = trailAlpha), - start = buttonCenter, - end = trailHead, - ), + if (phase == LaunchPhase.Launch) { + Canvas(modifier = Modifier.fillMaxSize()) { + val travel = controller.iconTravel.value + if (travel <= 0.02f) return@Canvas + val trailAlpha = when { + travel < 0.4f -> travel / 0.4f + else -> (1f - (travel - 0.4f) / 0.6f).coerceAtLeast(0f) + } + if (trailAlpha <= 0f) return@Canvas + val trailHead = lerp(buttonCenter, dialCenter, travel) + drawLine( + brush = Brush.linearGradient( + 0f to Color.Transparent, + 0.25f to theme.accent.copy(alpha = 0.35f * trailAlpha), + 0.75f to theme.accent.copy(alpha = 1f * trailAlpha), + 1f to Color.White.copy(alpha = trailAlpha), start = buttonCenter, end = trailHead, - strokeWidth = 10.dp.toPx(), - cap = StrokeCap.Round, - ) - } + ), + start = buttonCenter, + end = trailHead, + strokeWidth = 10.dp.toPx(), + cap = StrokeCap.Round, + ) } } @@ -210,32 +228,36 @@ fun LaunchOverlay( modifier = Modifier .size(60.dp) .graphicsLayer { + val travel = controller.iconTravel.value + val current = lerp(buttonCenter, dialCenter, travel) val halfPx = 30.dp.toPx() - translationX = currentX - halfPx - translationY = currentY - halfPx + translationX = current.x - halfPx + translationY = current.y - halfPx rotationZ = controller.iconRotationDeg.value - scaleX = iconScaleValue - scaleY = iconScaleValue - alpha = alphaValue + scaleX = controller.iconScale.value + scaleY = controller.iconScale.value + // Icon fadet über die letzten 20% des Fluges aus — matched den Prototyp. + alpha = if (travel < 0.8f) 1f else (1f - (travel - 0.8f) / 0.2f).coerceIn(0f, 1f) }, contentAlignment = Alignment.Center, ) { - if (glowAlpha > 0f) { - Canvas(modifier = Modifier.size(60.dp)) { - drawCircle( - brush = Brush.radialGradient( - 0f to glowColor.copy(alpha = glowAlpha), - 1f to glowColor.copy(alpha = 0f), - radius = size.minDimension / 2f, - ), + Canvas(modifier = Modifier.size(60.dp)) { + // Glow um das Icon herum — wächst mit Entfernung vom Button. + val glowAlpha = controller.iconTravel.value.coerceIn(0f, 1f) * 0.9f + if (glowAlpha <= 0f) return@Canvas + drawCircle( + brush = Brush.radialGradient( + 0f to theme.accent.copy(alpha = glowAlpha), + 1f to theme.accent.copy(alpha = 0f), radius = size.minDimension / 2f, - ) - } + ), + radius = size.minDimension / 2f, + ) } Icon( imageVector = Icons.Default.PlayArrow, contentDescription = null, - tint = iconTint, + tint = theme.accentInk, modifier = Modifier.size(34.dp), ) } diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/PlayButton.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/PlayButton.kt index 8a2a9f4..f91bcdf 100644 --- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/PlayButton.kt +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/PlayButton.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.Icon import androidx.compose.runtime.Composable @@ -36,11 +37,19 @@ import dev.xitee.sleeptimer.feature.timer.R import dev.xitee.sleeptimer.feature.timer.theme.appTheme /** - * Der Play/Stop-Button. Das Play-Icon wird NICHT hier gezeichnet — es lebt im - * `LaunchOverlay`, um kontinuierlich animiert zu werden (Crouch, Flug, Impact) - * ohne Wechsel zwischen zwei Icon-Instanzen. Hier wird nur das Stop-Icon - * gerendert (via Crossfade), sobald der Timer läuft. Im Idle-Zustand ist die - * Button-Fläche leer und der Overlay-Icon sitzt visuell zentriert darauf. + * Der Play/Stop-Button. Der Button zeichnet sein Icon selbst (inkl. Orientierungs- + * Rotation und Play↔Stop-Crossfade). Nur während die Launch-Animation das Icon + * fliegt, wird das Play-Icon hier per Alpha versteckt ([hidePlayIcon]) und vom + * `LaunchOverlay` an seiner Stelle gerendert. + * + * @param isRunning Visueller Zustand (Stop-Icon + Shape-Morph). Läuft die Launch- + * Animation, bleibt das bewusst false, bis sie fertig ist. + * @param describesRunning Semantischer Zustand für Accessibility: was ein Tap JETZT + * tut. Während der Animation stoppt ein Tap den bereits + * laufenden Timer, obwohl visuell noch kein Stop-Icon zu + * sehen ist — die Ansage muss der Aktion folgen. + * @param buttonScale Als Lambda, gelesen im graphicsLayer-Block: Animations-Frames + * sind so reine Layer-Updates ohne Recomposition. */ @Composable fun PlayButton( @@ -49,7 +58,9 @@ fun PlayButton( onClick: () -> Unit, modifier: Modifier = Modifier, iconRotation: Float = 0f, - buttonScale: Float = 1f, + buttonScale: () -> Float = { 1f }, + hidePlayIcon: Boolean = false, + describesRunning: Boolean = isRunning, ) { val theme = appTheme() val view = LocalView.current @@ -75,15 +86,16 @@ fun PlayButton( } val description = stringResource( - if (isRunning) R.string.stop_timer else R.string.start_timer, + if (describesRunning) R.string.stop_timer else R.string.start_timer, ) Box( modifier = modifier .size(84.dp) .graphicsLayer { - scaleX = buttonScale - scaleY = buttonScale + val scale = buttonScale() + scaleX = scale + scaleY = scale } .then(shadowModifier) .clip(shape) @@ -116,18 +128,21 @@ fun PlayButton( Crossfade( targetState = isRunning, animationSpec = tween(durationMillis = 180), - label = "stopIconFade", + label = "playIcon", ) { running -> - if (running) { - Icon( - imageVector = Icons.Default.Stop, - contentDescription = null, - tint = theme.accentInk, - modifier = Modifier - .size(34.dp) - .graphicsLayer { rotationZ = iconRotation }, - ) - } + val icon: ImageVector = if (running) Icons.Default.Stop else Icons.Default.PlayArrow + Icon( + imageVector = icon, + contentDescription = null, + tint = theme.accentInk, + modifier = Modifier + .size(34.dp) + .graphicsLayer { + rotationZ = iconRotation + // Während des Fluges rendert das LaunchOverlay das Play-Icon. + alpha = if (!running && hidePlayIcon) 0f else 1f + }, + ) } } }