From f9e4fd3acfc1b4a6e39b0e805a3df605e43effb3 Mon Sep 17 00:00:00 2001 From: Xitee1 <59659167+Xitee1@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:19:34 +0200 Subject: [PATCH 01/17] feat(core-data): add system reduce-motion detection utility --- .../sleeptimer/core/data/util/ReduceMotion.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/util/ReduceMotion.kt diff --git a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/util/ReduceMotion.kt b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/util/ReduceMotion.kt new file mode 100644 index 0000000..7243856 --- /dev/null +++ b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/util/ReduceMotion.kt @@ -0,0 +1,18 @@ +package dev.xitee.sleeptimer.core.data.util + +import android.content.Context +import android.provider.Settings + +/** + * Gibt true zurück, wenn der Nutzer in den System-Einstellungen „Animationen entfernen" + * aktiviert hat. Erkennung über `Settings.Global.ANIMATOR_DURATION_SCALE == 0f`, was + * von Accessibility-Settings und den Developer-Options identisch gesetzt wird. + */ +fun isSystemReduceMotionEnabled(context: Context): Boolean { + val scale = Settings.Global.getFloat( + context.contentResolver, + Settings.Global.ANIMATOR_DURATION_SCALE, + 1f, + ) + return scale == 0f +} From 1d3275d24636bffaa60d2f3487a3027ec0737f33 Mon Sep 17 00:00:00 2001 From: Xitee1 <59659167+Xitee1@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:23:48 +0200 Subject: [PATCH 02/17] feat(core-data): add launchAnimationEnabled setting with reduce-motion seed Co-Authored-By: Claude Opus 4.7 (1M context) --- .../core/data/model/UserSettings.kt | 1 + .../data/repository/SettingsRepository.kt | 1 + .../data/repository/SettingsRepositoryImpl.kt | 34 +++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/UserSettings.kt b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/UserSettings.kt index d6a6eda..8ba173d 100644 --- a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/UserSettings.kt +++ b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/UserSettings.kt @@ -12,4 +12,5 @@ data class UserSettings( val starsEnabled: Boolean = true, val stepMinutes: Int = 5, val presetMinutes: Int = 15, + val launchAnimationEnabled: Boolean = true, ) diff --git a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepository.kt b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepository.kt index afba765..7038646 100644 --- a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepository.kt +++ b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepository.kt @@ -17,4 +17,5 @@ interface SettingsRepository { suspend fun updateStarsEnabled(enabled: Boolean) suspend fun updateStepMinutes(minutes: Int) suspend fun updatePresetMinutes(minutes: Int) + suspend fun updateLaunchAnimationEnabled(enabled: Boolean) } 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 80dca3f..ad79af3 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 @@ -1,21 +1,29 @@ package dev.xitee.sleeptimer.core.data.repository +import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import dagger.hilt.android.qualifiers.ApplicationContext import dev.xitee.sleeptimer.core.data.model.ThemeId import dev.xitee.sleeptimer.core.data.model.UserSettings +import dev.xitee.sleeptimer.core.data.util.isSystemReduceMotionEnabled +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @Singleton class SettingsRepositoryImpl @Inject constructor( private val dataStore: DataStore, + @ApplicationContext private val context: Context, ) : SettingsRepository { private companion object { @@ -30,6 +38,27 @@ class SettingsRepositoryImpl @Inject constructor( val STARS_ENABLED = booleanPreferencesKey("stars_enabled") 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. + 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). + initScope.launch { + dataStore.edit { prefs -> + if (prefs[LAUNCH_ANIMATION_SEEDED] != true) { + prefs[LAUNCH_ANIMATION_ENABLED] = !isSystemReduceMotionEnabled(context) + prefs[LAUNCH_ANIMATION_SEEDED] = true + } + } + } } override val settings: Flow = dataStore.data.map { prefs -> @@ -48,6 +77,7 @@ class SettingsRepositoryImpl @Inject constructor( starsEnabled = prefs[STARS_ENABLED] ?: d.starsEnabled, stepMinutes = prefs[STEP_MINUTES] ?: d.stepMinutes, presetMinutes = prefs[PRESET_MINUTES] ?: d.presetMinutes, + launchAnimationEnabled = prefs[LAUNCH_ANIMATION_ENABLED] ?: d.launchAnimationEnabled, ) } @@ -94,4 +124,8 @@ class SettingsRepositoryImpl @Inject constructor( override suspend fun updatePresetMinutes(minutes: Int) { dataStore.edit { it[PRESET_MINUTES] = minutes.coerceIn(1, 300) } } + + override suspend fun updateLaunchAnimationEnabled(enabled: Boolean) { + dataStore.edit { it[LAUNCH_ANIMATION_ENABLED] = enabled } + } } From c67f8c6b5a15079ee1120a1ecb31c8610db42b68 Mon Sep 17 00:00:00 2001 From: Xitee1 <59659167+Xitee1@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:28:43 +0200 Subject: [PATCH 03/17] feat(settings): add launch animation toggle --- .../sleeptimer/feature/timer/settings/SettingsScreen.kt | 8 ++++++++ .../feature/timer/settings/SettingsViewModel.kt | 4 ++++ feature/timer/src/main/res/values-de/strings.xml | 2 ++ feature/timer/src/main/res/values/strings.xml | 2 ++ 4 files changed, 16 insertions(+) diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsScreen.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsScreen.kt index b62ae20..fd90659 100644 --- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsScreen.kt +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.MusicOff import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.RocketLaunch import androidx.compose.material.icons.filled.Vibration import androidx.compose.material.icons.filled.Wifi import androidx.compose.material3.Icon @@ -224,6 +225,13 @@ private fun SettingsContent( onCheckedChange = { viewModel.updateStarsEnabled(it) }, enabled = AppThemes.byId(uiState.settings.theme).allowStars, ) + SettingsToggleRow( + icon = Icons.Default.RocketLaunch, + title = stringResource(R.string.launch_animation_title), + description = stringResource(R.string.launch_animation_description), + checked = uiState.settings.launchAnimationEnabled, + onCheckedChange = { viewModel.updateLaunchAnimationEnabled(it) }, + ) SectionHeader(stringResource(R.string.category_sleep_timer)) SettingsToggleRow( diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsViewModel.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsViewModel.kt index 9529424..d06c053 100644 --- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsViewModel.kt +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsViewModel.kt @@ -103,4 +103,8 @@ class SettingsViewModel @Inject constructor( fun updateStepMinutes(minutes: Int) { viewModelScope.launch { settingsRepository.updateStepMinutes(minutes) } } + + fun updateLaunchAnimationEnabled(enabled: Boolean) { + viewModelScope.launch { settingsRepository.updateLaunchAnimationEnabled(enabled) } + } } diff --git a/feature/timer/src/main/res/values-de/strings.xml b/feature/timer/src/main/res/values-de/strings.xml index 75de465..fa4e454 100644 --- a/feature/timer/src/main/res/values-de/strings.xml +++ b/feature/timer/src/main/res/values-de/strings.xml @@ -20,6 +20,8 @@ Erscheinungsbild Sternen-Hintergrund Driftendes Sternenfeld hinter dem Dial + Launch-Animation + Raketenartige Animation des Play-Buttons beim Timer-Start Für dieses Theme nicht verfügbar Sleep Timer Wiedergabe diff --git a/feature/timer/src/main/res/values/strings.xml b/feature/timer/src/main/res/values/strings.xml index 3ed966c..80118a0 100644 --- a/feature/timer/src/main/res/values/strings.xml +++ b/feature/timer/src/main/res/values/strings.xml @@ -20,6 +20,8 @@ Appearance Stars background Drifting starfield behind the dial + Launch animation + Rocket-style play button animation when starting the timer Not available on this theme Sleep Timer Playback From c42b9346ea6af4b9b7973cbddace10774df8ec93 Mon Sep 17 00:00:00 2001 From: Xitee1 <59659167+Xitee1@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:33:09 +0200 Subject: [PATCH 04/17] feat(timer-ui): extend PlayButton with crouch + launching parameters Co-Authored-By: Claude Sonnet 4.6 --- .../timer/timer/components/PlayButton.kt | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) 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 dbfb756..bdc7da5 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 @@ -41,6 +41,9 @@ fun PlayButton( onClick: () -> Unit, modifier: Modifier = Modifier, iconRotation: Float = 0f, + crouchProgress: Float = 0f, + iconLaunching: Boolean = false, + targetIconRotationDeg: Float = 0f, ) { val theme = appTheme() val view = LocalView.current @@ -65,9 +68,22 @@ fun PlayButton( Modifier } + // Linear interpolate button scale: 1.0 at crouchProgress=0, 0.92 at crouchProgress=1 + val buttonScale = 1f - 0.08f * crouchProgress.coerceIn(0f, 1f) + // Icon rotation during crouch: 0° → targetIconRotationDeg as crouchProgress goes 0→1. + // `iconRotation` (orientation-based) is added on top so landscape still rotates the + // idle icon correctly. + val crouchRotation = targetIconRotationDeg * crouchProgress.coerceIn(0f, 1f) + // Icon scale during crouch: 1.0 → 0.9 + val crouchIconScale = 1f - 0.1f * crouchProgress.coerceIn(0f, 1f) + Box( modifier = modifier .size(84.dp) + .graphicsLayer { + scaleX = buttonScale + scaleY = buttonScale + } .then(shadowModifier) .clip(shape) .clickable { @@ -102,13 +118,20 @@ fun PlayButton( ) { running -> val icon: ImageVector = if (running) Icons.Default.Stop else Icons.Default.PlayArrow val desc = stringResource(if (running) R.string.stop_timer else R.string.start_timer) + // Play icon hides while the launch overlay is flying. + val iconAlpha = if (!running && iconLaunching) 0f else 1f Icon( imageVector = icon, contentDescription = desc, tint = theme.accentInk, modifier = Modifier .size(34.dp) - .graphicsLayer { rotationZ = iconRotation }, + .graphicsLayer { + rotationZ = iconRotation + (if (!running) crouchRotation else 0f) + scaleX = if (!running) crouchIconScale else 1f + scaleY = if (!running) crouchIconScale else 1f + alpha = iconAlpha + }, ) } } From 50a51c4eb2e725c81b0b798bd1695640f57a6331 Mon Sep 17 00:00:00 2001 From: Xitee1 <59659167+Xitee1@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:38:05 +0200 Subject: [PATCH 05/17] feat(timer-ui): add impact pulse rendering to CircularDial --- .../timer/timer/components/CircularDial.kt | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) 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 f7c1b32..8250317 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 @@ -43,6 +43,7 @@ fun CircularDial( onMinutesChanged: (Int) -> Unit, onMinutesCommitted: (Int) -> Unit, modifier: Modifier = Modifier, + impactPulse: Float = 0f, ) { val theme = appTheme() val view = LocalView.current @@ -200,6 +201,17 @@ fun CircularDial( theme = theme, dimmed = isRunning && !state.isDragging, ) + + if (impactPulse > 0f) { + drawImpactEffects( + center = center, + ringRadius = radius, + strokeWidth = strokeWidth, + knobFraction = ringFraction, + pulse = impactPulse.coerceIn(0f, 1f), + accent = theme.accent, + ) + } } } } @@ -377,3 +389,58 @@ private fun DrawScope.drawKnob( center = Offset(kx, ky), ) } + +private fun DrawScope.drawImpactEffects( + center: Offset, + ringRadius: Float, + strokeWidth: Float, + knobFraction: Float, + pulse: Float, + accent: Color, +) { + // Knob-Position reproducing drawKnob's math. + val angle = knobFraction * (2f * Math.PI.toFloat()) - (Math.PI.toFloat() / 2f) + val kx = center.x + ringRadius * cos(angle) + val ky = center.y + ringRadius * sin(angle) + + // 1) Knob aura bloom — radius expands, alpha fades. + val auraRadius = 16.dp.toPx() + (60.dp.toPx() - 16.dp.toPx()) * pulse + val auraAlpha = (1f - pulse).coerceAtLeast(0f) * 0.55f + drawCircle( + color = accent.copy(alpha = auraAlpha), + radius = auraRadius, + center = Offset(kx, ky), + ) + + // 2) Three concentric shockwave ripples, phase-shifted. + // Each ripple has its own normalized lifetime within the impact pulse. + val rippleOffsets = floatArrayOf(0f, 0.12f, 0.24f) + for (offset in rippleOffsets) { + val local = ((pulse - offset) / (1f - offset)).coerceIn(0f, 1f) + if (local <= 0f) continue + val rippleRadius = 10.dp.toPx() + (70.dp.toPx() - 10.dp.toPx()) * local + val rippleStroke = (6.dp.toPx() - 5.5f.dp.toPx() * local).coerceAtLeast(0.5f.dp.toPx()) + val rippleAlpha = (1f - local) * 0.9f + drawCircle( + color = accent.copy(alpha = rippleAlpha), + radius = rippleRadius, + center = Offset(kx, ky), + style = Stroke(width = rippleStroke), + ) + } + + // 3) Ring-Alpha-Boost: bright flash layered over the existing progress arc. + // Eases in quickly (0→0.3) and fades over the rest of the pulse. + val boostAlpha = when { + pulse < 0.3f -> pulse / 0.3f + else -> (1f - pulse) / 0.7f + }.coerceIn(0f, 1f) * 0.4f + if (boostAlpha > 0f) { + drawCircle( + color = accent.copy(alpha = boostAlpha), + radius = ringRadius, + center = center, + style = Stroke(width = strokeWidth, cap = StrokeCap.Round), + ) + } +} From 7400626843a955166874fcc6bea706bd69b1e348 Mon Sep 17 00:00:00 2001 From: Xitee1 <59659167+Xitee1@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:41:48 +0200 Subject: [PATCH 06/17] perf(timer-ui): hoist impact ripple offsets to file-level constant Avoids per-frame floatArrayOf allocation inside drawImpactEffects during the ~260ms impact phase. Negligible real-world impact but cleaner. --- .../feature/timer/timer/components/CircularDial.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 8250317..206639e 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 @@ -414,8 +414,7 @@ private fun DrawScope.drawImpactEffects( // 2) Three concentric shockwave ripples, phase-shifted. // Each ripple has its own normalized lifetime within the impact pulse. - val rippleOffsets = floatArrayOf(0f, 0.12f, 0.24f) - for (offset in rippleOffsets) { + for (offset in IMPACT_RIPPLE_OFFSETS) { val local = ((pulse - offset) / (1f - offset)).coerceIn(0f, 1f) if (local <= 0f) continue val rippleRadius = 10.dp.toPx() + (70.dp.toPx() - 10.dp.toPx()) * local @@ -444,3 +443,5 @@ private fun DrawScope.drawImpactEffects( ) } } + +private val IMPACT_RIPPLE_OFFSETS = floatArrayOf(0f, 0.12f, 0.24f) From 17fd9eb8b53eef972704b6de1ddb9da35071e607 Mon Sep 17 00:00:00 2001 From: Xitee1 <59659167+Xitee1@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:45:47 +0200 Subject: [PATCH 07/17] feat(timer-ui): add LaunchAnimationController and overlay composable Co-Authored-By: Claude Sonnet 4.6 --- .../timer/timer/components/LaunchAnimation.kt | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/LaunchAnimation.kt 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 new file mode 100644 index 0000000..6755549 --- /dev/null +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/LaunchAnimation.kt @@ -0,0 +1,166 @@ +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.tween +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +enum class LaunchPhase { Idle, Crouch, Launch, Impact } + +/** + * Orchestriert die Rocket-Launch-Animation in einer Coroutine. Hält alle Animatable-Werte + * als Public-Properties, damit `TimerScreen` und `LaunchOverlay` sie lesen können. + * + * Der Controller weiß nichts vom Service oder Timer-State — er spielt nur die visuelle + * Choreographie ab. + */ +class LaunchAnimationController(private val scope: CoroutineScope) { + var phase by mutableStateOf(LaunchPhase.Idle) + private set + + // 1.0 im Idle, 0.92 auf dem Höhepunkt des Crouch, 1.04 beim Impact-Recoil. + val buttonScale = Animatable(1f) + // Absoluter Winkel des Play-Icons in Grad. + val iconRotationDeg = Animatable(0f) + // Fortschritt der Icon-Reise: 0 = Button-Center, 1 = Dial-Center. + val iconTravel = Animatable(0f) + // Icon-Scale während des Fluges (1.1 → 0.2 für Perspektivillusion). + val iconScale = Animatable(1f) + // Crouch-Intensität 0..1, steuert Button-Schrumpfung im PlayButton. + val crouchProgress = Animatable(0f) + // Impact-Pulse 0..1, wird ans Dial weitergereicht. + val impactPulse = Animatable(0f) + + private var currentJob: Job? = null + + /** + * 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). + */ + fun launch(targetIconRotationDeg: Float) { + if (phase != LaunchPhase.Idle) return + 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 { crouchProgress.animateTo(1f, crouchSpec) } + delay(140) + + // Phase 2: Launch (140–560ms) + phase = LaunchPhase.Launch + val launchEasing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f) + val launchSpec = tween(420, easing = launchEasing) + launch { buttonScale.animateTo(1f, tween(180)) } + launch { iconTravel.animateTo(1f, launchSpec) } + // Icon scale: 1.0 → 1.1 → 0.2 across the flight (rough approximation using two segments). + launch { + iconScale.animateTo(1.1f, tween(120, easing = launchEasing)) + iconScale.animateTo(0.2f, tween(300, easing = launchEasing)) + } + delay(420) + + // Phase 3: Impact (560–820ms) + phase = LaunchPhase.Impact + val impactEasing = CubicBezierEasing(0.2f, 0.7f, 0.3f, 1f) + val impactSpec = tween(260, easing = impactEasing) + launch { + buttonScale.animateTo( + 1.04f, + tween(130, easing = CubicBezierEasing(0.2f, 1.8f, 0.4f, 1f)), + ) + buttonScale.animateTo(1f, tween(130)) + } + launch { impactPulse.animateTo(1f, impactSpec) } + delay(260) + + // Zurück auf Idle (snap, nicht animiert, weil nächstes Frame den echten Running-State hat). + reset() + } + } + + /** + * Bricht eine laufende Animation ab und snapt alle Werte auf Idle-Defaults zurück. + */ + fun reset() { + currentJob?.cancel() + currentJob = null + scope.launch { + buttonScale.snapTo(1f) + iconRotationDeg.snapTo(0f) + iconTravel.snapTo(0f) + iconScale.snapTo(1f) + crouchProgress.snapTo(0f) + impactPulse.snapTo(0f) + } + phase = LaunchPhase.Idle + } +} + +@Composable +fun rememberLaunchAnimationController(): LaunchAnimationController { + val scope = rememberCoroutineScope() + return remember(scope) { LaunchAnimationController(scope) } +} + +@Composable +fun LaunchOverlay( + controller: LaunchAnimationController, + buttonCenter: Offset, + dialCenter: Offset, + accentColor: Color, +) { + // Nur während der Flug-Phase rendern. In Crouch ist das Icon noch im Button, + // in Impact ist es „eingeschlagen" (und die Dial-Effekte übernehmen). + if (controller.phase != LaunchPhase.Launch) return + // Warten bis beide Positionen gemessen wurden — sonst würde das Icon in Frame 1 + // kurz bei (0,0) aufblitzen. + if (buttonCenter == Offset.Zero || dialCenter == Offset.Zero) return + + val travel = controller.iconTravel.value + val iconScaleValue = controller.iconScale.value + // Fade-out gegen Ende des Fluges, damit der Impact „nahtlos" übernimmt. + val alphaValue = + if (travel < 0.85f) 1f else (1f - (travel - 0.85f) / 0.15f).coerceIn(0f, 1f) + + val currentX = buttonCenter.x + (dialCenter.x - buttonCenter.x) * travel + val currentY = buttonCenter.y + (dialCenter.y - buttonCenter.y) * travel + + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = null, + tint = accentColor, + modifier = Modifier + .size(34.dp) + .graphicsLayer { + val halfPx = 17.dp.toPx() // Icon ist 34dp; Center-Offset ist die Hälfte. + translationX = currentX - halfPx + translationY = currentY - halfPx + rotationZ = controller.iconRotationDeg.value + scaleX = iconScaleValue + scaleY = iconScaleValue + alpha = alphaValue + }, + ) +} From 91b683363244e642b3cf7dacbe78a7dee3a4d8da Mon Sep 17 00:00:00 2001 From: Xitee1 <59659167+Xitee1@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:50:08 +0200 Subject: [PATCH 08/17] refactor(timer-ui): drive PlayButton scale externally instead of deriving from crouchProgress The launch animation has three distinct button-scale moments (1.0 at idle, 0.92 at crouch, 1.04 impact recoil). Deriving buttonScale from crouchProgress can only express the first two; the impact recoil needs a separate driver. Add an explicit buttonScale parameter so the LaunchAnimationController can drive the full 820ms scale trajectory directly. crouchProgress now drives only the icon rotation/scale during crouch. --- .../sleeptimer/feature/timer/timer/components/PlayButton.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 bdc7da5..9a256b0 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 @@ -44,6 +44,7 @@ fun PlayButton( crouchProgress: Float = 0f, iconLaunching: Boolean = false, targetIconRotationDeg: Float = 0f, + buttonScale: Float = 1f, ) { val theme = appTheme() val view = LocalView.current @@ -68,8 +69,6 @@ fun PlayButton( Modifier } - // Linear interpolate button scale: 1.0 at crouchProgress=0, 0.92 at crouchProgress=1 - val buttonScale = 1f - 0.08f * crouchProgress.coerceIn(0f, 1f) // Icon rotation during crouch: 0° → targetIconRotationDeg as crouchProgress goes 0→1. // `iconRotation` (orientation-based) is added on top so landscape still rotates the // idle icon correctly. From 9f7a568c26de8578289f1f4f49407128c782c0f4 Mon Sep 17 00:00:00 2001 From: Xitee1 <59659167+Xitee1@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:54:52 +0200 Subject: [PATCH 09/17] feat(timer-ui): wire launch animation into TimerScreen --- .../feature/timer/timer/TimerScreen.kt | 73 ++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) 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 b402ec8..030bfbf 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 @@ -48,7 +48,10 @@ import androidx.compose.runtime.remember 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.graphics.graphicsLayer +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource @@ -56,7 +59,10 @@ import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.xitee.sleeptimer.core.data.util.isSystemReduceMotionEnabled import dev.xitee.sleeptimer.core.data.util.remainingMillisToDisplayMinutes import dev.xitee.sleeptimer.core.service.shizuku.ShizukuManager import dev.xitee.sleeptimer.feature.timer.R @@ -67,11 +73,15 @@ import dev.xitee.sleeptimer.feature.timer.theme.LocalAppTheme import dev.xitee.sleeptimer.feature.timer.theme.appTheme import dev.xitee.sleeptimer.feature.timer.theme.rememberAnimatedAppTheme import dev.xitee.sleeptimer.feature.timer.timer.components.CircularDial +import dev.xitee.sleeptimer.feature.timer.timer.components.LaunchOverlay +import dev.xitee.sleeptimer.feature.timer.timer.components.LaunchPhase import dev.xitee.sleeptimer.feature.timer.timer.components.PlayButton import dev.xitee.sleeptimer.feature.timer.timer.components.SecondaryRoundButton import dev.xitee.sleeptimer.feature.timer.timer.components.TimeDisplay import dev.xitee.sleeptimer.feature.timer.timer.components.TimerBackground import dev.xitee.sleeptimer.feature.timer.timer.components.rememberCircularDialState +import dev.xitee.sleeptimer.feature.timer.timer.components.rememberLaunchAnimationController +import kotlin.math.atan2 private const val ROTATION_DURATION_MS = 350 @@ -106,6 +116,17 @@ private fun TimerContent( orientation == DeviceOrientation.LANDSCAPE_RIGHT val animatedAngle = animatedRotationAngle(orientation) + 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) { + launchController.reset() + } + val notificationPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission(), ) { _ -> @@ -289,7 +310,10 @@ private fun TimerContent( Box( modifier = Modifier .size(dialSize) - .graphicsLayer { rotationZ = animatedAngle }, + .graphicsLayer { rotationZ = animatedAngle } + .onGloballyPositioned { coords -> + dialCenter = coords.boundsInRoot().center + }, contentAlignment = Alignment.Center, ) { CircularDial( @@ -299,6 +323,7 @@ private fun TimerContent( hapticEnabled = settings.hapticFeedbackEnabled, onMinutesChanged = viewModel::setMinutes, onMinutesCommitted = viewModel::commitMinutes, + impactPulse = launchController.impactPulse.value, modifier = Modifier.fillMaxSize(), ) @@ -333,12 +358,26 @@ private fun TimerContent( else -> 0 } + val launchPhase = launchController.phase + val iconLaunching = launchPhase == LaunchPhase.Launch || + launchPhase == LaunchPhase.Impact + // 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, hapticEnabled = settings.hapticFeedbackEnabled, iconRotation = animatedAngle, onToggle = { - if (isRunning) { + val animating = launchPhase != LaunchPhase.Idle + if (isRunning || animating) { viewModel.stopTimer() } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission( @@ -349,6 +388,12 @@ private fun TimerContent( notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } else { viewModel.startTimer() + if (animationEnabled && + buttonCenter != Offset.Zero && + dialCenter != Offset.Zero + ) { + launchController.launch(targetIconAngleDeg) + } } }, onMinusStep = { @@ -378,6 +423,11 @@ private fun TimerContent( }, isPlusEnabled = !isRunning && dialState.totalMinutes < 300, plusStepVisibleWhileRunning = true, + crouchProgress = launchController.crouchProgress.value, + iconLaunching = iconLaunching, + targetIconRotationDeg = targetIconAngleDeg, + buttonScale = launchController.buttonScale.value, + onButtonPositioned = { buttonCenter = it }, ) Spacer(modifier = Modifier.height(32.dp)) @@ -415,6 +465,13 @@ private fun TimerContent( ) } } + + LaunchOverlay( + controller = launchController, + buttonCenter = buttonCenter, + dialCenter = dialCenter, + accentColor = appTheme().accent, + ) } } @@ -490,6 +547,11 @@ private fun ActionRow( isMinusEnabled: Boolean, isPlusEnabled: Boolean, plusStepVisibleWhileRunning: Boolean, + crouchProgress: Float, + iconLaunching: Boolean, + targetIconRotationDeg: Float, + buttonScale: Float, + onButtonPositioned: (Offset) -> Unit, ) { androidx.compose.foundation.layout.Row( modifier = Modifier @@ -511,6 +573,13 @@ private fun ActionRow( hapticEnabled = hapticEnabled, onClick = onToggle, iconRotation = iconRotation, + crouchProgress = crouchProgress, + iconLaunching = iconLaunching, + targetIconRotationDeg = targetIconRotationDeg, + buttonScale = buttonScale, + modifier = Modifier.onGloballyPositioned { coords -> + onButtonPositioned(coords.boundsInRoot().center) + }, ) SecondaryRoundButton( icon = Icons.Default.Add, From cb6633b8ca535d44247d32a2974b9cedbf26934d Mon Sep 17 00:00:00 2001 From: Xitee1 <59659167+Xitee1@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:21:27 +0200 Subject: [PATCH 10/17] refactor(timer-ui): move play icon entirely to overlay and widen impact effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three linked design fixes after initial testing feedback: 1. Play icon lives only in the LaunchOverlay now — removed from PlayButton. Overlay renders it at button center during Idle/Crouch and animates position/rotation/scale during Launch. No more icon handoff between button and overlay, so no color jump or scale discontinuity at takeoff. A soft accent-colored glow fades in as the icon leaves the button, keeping it readable against the dark background. 2. Impact effect now hits the whole dial instead of the draggable knob. drawImpactEffects fills the dial's inner area with a bright center flash, radiates three shockwave rings from the center outward past the ring edge, and keeps the progress-arc brightness boost. The knob no longer gets its own aura — the knob is purely an interaction affordance, not an animation target. 3. Icon-scale compression during crouch is now driven by the controller's iconScale Animatable (0.9 at end of crouch), so the overlay-rendered icon visibly "crouches" just like the prototype. crouchProgress has been dropped — it was only wired to the button-internal icon which no longer exists. --- .../feature/timer/timer/TimerScreen.kt | 15 +--- .../timer/timer/components/CircularDial.kt | 53 +++++++------ .../timer/timer/components/LaunchAnimation.kt | 77 ++++++++++++++----- .../timer/timer/components/PlayButton.kt | 54 ++++++------- 4 files changed, 114 insertions(+), 85 deletions(-) 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 030bfbf..0d5646f 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 @@ -359,8 +359,6 @@ private fun TimerContent( } val launchPhase = launchController.phase - val iconLaunching = launchPhase == LaunchPhase.Launch || - launchPhase == LaunchPhase.Impact // 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 @@ -423,9 +421,6 @@ private fun TimerContent( }, isPlusEnabled = !isRunning && dialState.totalMinutes < 300, plusStepVisibleWhileRunning = true, - crouchProgress = launchController.crouchProgress.value, - iconLaunching = iconLaunching, - targetIconRotationDeg = targetIconAngleDeg, buttonScale = launchController.buttonScale.value, onButtonPositioned = { buttonCenter = it }, ) @@ -468,9 +463,11 @@ private fun TimerContent( LaunchOverlay( controller = launchController, + isRunning = isRunning, buttonCenter = buttonCenter, dialCenter = dialCenter, - accentColor = appTheme().accent, + iconTint = appTheme().accentInk, + glowColor = appTheme().accent, ) } } @@ -547,9 +544,6 @@ private fun ActionRow( isMinusEnabled: Boolean, isPlusEnabled: Boolean, plusStepVisibleWhileRunning: Boolean, - crouchProgress: Float, - iconLaunching: Boolean, - targetIconRotationDeg: Float, buttonScale: Float, onButtonPositioned: (Offset) -> Unit, ) { @@ -573,9 +567,6 @@ private fun ActionRow( hapticEnabled = hapticEnabled, onClick = onToggle, iconRotation = iconRotation, - crouchProgress = crouchProgress, - iconLaunching = iconLaunching, - targetIconRotationDeg = targetIconRotationDeg, buttonScale = buttonScale, 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 206639e..b234dfb 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 @@ -207,7 +207,6 @@ fun CircularDial( center = center, ringRadius = radius, strokeWidth = strokeWidth, - knobFraction = ringFraction, pulse = impactPulse.coerceIn(0f, 1f), accent = theme.accent, ) @@ -394,42 +393,50 @@ private fun DrawScope.drawImpactEffects( center: Offset, ringRadius: Float, strokeWidth: Float, - knobFraction: Float, pulse: Float, accent: Color, ) { - // Knob-Position reproducing drawKnob's math. - val angle = knobFraction * (2f * Math.PI.toFloat()) - (Math.PI.toFloat() / 2f) - val kx = center.x + ringRadius * cos(angle) - val ky = center.y + ringRadius * sin(angle) - - // 1) Knob aura bloom — radius expands, alpha fades. - val auraRadius = 16.dp.toPx() + (60.dp.toPx() - 16.dp.toPx()) * pulse - val auraAlpha = (1f - pulse).coerceAtLeast(0f) * 0.55f - drawCircle( - color = accent.copy(alpha = auraAlpha), - radius = auraRadius, - center = Offset(kx, ky), - ) + // 1) Center flash: hell-gefüllter Kreis vom Dial-Zentrum, der kurz aufflashed + // und dann ausklingt. Füllt die Innenfläche des Dials während des Impact-Peaks. + val flashInnerRadius = ringRadius - strokeWidth * 0.5f - 6.dp.toPx() + val flashEaseIn = (pulse / 0.2f).coerceIn(0f, 1f) + val flashEaseOut = ((1f - pulse) / 0.8f).coerceIn(0f, 1f) + val flashAlpha = (flashEaseIn * flashEaseOut) * 0.55f + if (flashAlpha > 0f) { + drawCircle( + brush = Brush.radialGradient( + 0f to accent.copy(alpha = flashAlpha), + 1f to accent.copy(alpha = 0f), + center = center, + radius = flashInnerRadius, + ), + radius = flashInnerRadius, + center = center, + ) + } - // 2) Three concentric shockwave ripples, phase-shifted. - // Each ripple has its own normalized lifetime within the impact pulse. + // 2) Drei konzentrische Shockwave-Ripples vom Dial-Zentrum, phase-shifted. Sie + // beginnen als dicker Ring nahe der Dial-Mitte und expandieren bis knapp über den + // äußeren Rand — der Puls „schwappt" also über das ganze Dial. + val rippleStart = ringRadius * 0.2f + val rippleEnd = ringRadius * 1.15f for (offset in IMPACT_RIPPLE_OFFSETS) { val local = ((pulse - offset) / (1f - offset)).coerceIn(0f, 1f) if (local <= 0f) continue - val rippleRadius = 10.dp.toPx() + (70.dp.toPx() - 10.dp.toPx()) * local - val rippleStroke = (6.dp.toPx() - 5.5f.dp.toPx() * local).coerceAtLeast(0.5f.dp.toPx()) - val rippleAlpha = (1f - local) * 0.9f + val rippleRadius = rippleStart + (rippleEnd - rippleStart) * local + val rippleStroke = + (6.dp.toPx() - 5.5f.dp.toPx() * local).coerceAtLeast(0.5f.dp.toPx()) + val rippleAlpha = (1f - local) * 0.8f drawCircle( color = accent.copy(alpha = rippleAlpha), radius = rippleRadius, - center = Offset(kx, ky), + center = center, style = Stroke(width = rippleStroke), ) } - // 3) Ring-Alpha-Boost: bright flash layered over the existing progress arc. - // Eases in quickly (0→0.3) and fades over the rest of the pulse. + // 3) Ring-Alpha-Boost: heller Overlay über dem bestehenden Progress-Arc. + // Schneller Anstieg (0→0.3), langsames Ausklingen (0.3→1.0). val boostAlpha = when { pulse < 0.3f -> pulse / 0.3f else -> (1f - pulse) / 0.7f 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 6755549..26c310d 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 @@ -3,6 +3,8 @@ 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.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow @@ -13,8 +15,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope 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.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp @@ -42,10 +46,8 @@ class LaunchAnimationController(private val scope: CoroutineScope) { val iconRotationDeg = Animatable(0f) // Fortschritt der Icon-Reise: 0 = Button-Center, 1 = Dial-Center. val iconTravel = Animatable(0f) - // Icon-Scale während des Fluges (1.1 → 0.2 für Perspektivillusion). + // Icon-Scale: 1.0 idle, 0.9 crouch (komprimiert), 1.1 → 0.2 während des Fluges. val iconScale = Animatable(1f) - // Crouch-Intensität 0..1, steuert Button-Schrumpfung im PlayButton. - val crouchProgress = Animatable(0f) // Impact-Pulse 0..1, wird ans Dial weitergereicht. val impactPulse = Animatable(0f) @@ -65,7 +67,7 @@ class LaunchAnimationController(private val scope: CoroutineScope) { val crouchSpec = tween(140, easing = crouchEasing) launch { buttonScale.animateTo(0.92f, crouchSpec) } launch { iconRotationDeg.animateTo(targetIconRotationDeg, crouchSpec) } - launch { crouchProgress.animateTo(1f, crouchSpec) } + launch { iconScale.animateTo(0.9f, crouchSpec) } delay(140) // Phase 2: Launch (140–560ms) @@ -74,7 +76,7 @@ class LaunchAnimationController(private val scope: CoroutineScope) { val launchSpec = tween(420, easing = launchEasing) launch { buttonScale.animateTo(1f, tween(180)) } launch { iconTravel.animateTo(1f, launchSpec) } - // Icon scale: 1.0 → 1.1 → 0.2 across the flight (rough approximation using two segments). + // Icon scale: 0.9 → 1.1 → 0.2 across the flight. launch { iconScale.animateTo(1.1f, tween(120, easing = launchEasing)) iconScale.animateTo(0.2f, tween(300, easing = launchEasing)) @@ -111,7 +113,6 @@ class LaunchAnimationController(private val scope: CoroutineScope) { iconRotationDeg.snapTo(0f) iconTravel.snapTo(0f) iconScale.snapTo(1f) - crouchProgress.snapTo(0f) impactPulse.snapTo(0f) } phase = LaunchPhase.Idle @@ -124,37 +125,51 @@ fun rememberLaunchAnimationController(): LaunchAnimationController { return remember(scope) { LaunchAnimationController(scope) } } +/** + * 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. + */ @Composable fun LaunchOverlay( controller: LaunchAnimationController, + isRunning: Boolean, buttonCenter: Offset, dialCenter: Offset, - accentColor: Color, + iconTint: Color, + glowColor: Color, ) { - // Nur während der Flug-Phase rendern. In Crouch ist das Icon noch im Button, - // in Impact ist es „eingeschlagen" (und die Dial-Effekte übernehmen). - if (controller.phase != LaunchPhase.Launch) return - // Warten bis beide Positionen gemessen wurden — sonst würde das Icon in Frame 1 - // kurz bei (0,0) aufblitzen. - if (buttonCenter == Offset.Zero || dialCenter == Offset.Zero) return + 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 - // Fade-out gegen Ende des Fluges, damit der Impact „nahtlos" übernimmt. + // Fade-out kurz vor dem Impact, damit der Übergang zum Dial-Effekt weich wirkt. val alphaValue = if (travel < 0.85f) 1f else (1f - (travel - 0.85f) / 0.15f).coerceIn(0f, 1f) + // Glow wächst proportional zur Entfernung vom Button: auf dem Button fast unsichtbar, + // im freien Flug deutlich sichtbar. + 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 - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = null, - tint = accentColor, + // Container-Box ist 60dp (Glow-Radius). Icon ist 34dp zentriert darin. + Box( modifier = Modifier - .size(34.dp) + .size(60.dp) .graphicsLayer { - val halfPx = 17.dp.toPx() // Icon ist 34dp; Center-Offset ist die Hälfte. + val halfPx = 30.dp.toPx() translationX = currentX - halfPx translationY = currentY - halfPx rotationZ = controller.iconRotationDeg.value @@ -162,5 +177,25 @@ fun LaunchOverlay( scaleY = iconScaleValue alpha = alphaValue }, - ) + 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, + ), + radius = size.minDimension / 2f, + ) + } + } + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = null, + tint = iconTint, + 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 9a256b0..8a2a9f4 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,7 +13,6 @@ 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 @@ -30,10 +29,19 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp 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. + */ @Composable fun PlayButton( isRunning: Boolean, @@ -41,9 +49,6 @@ fun PlayButton( onClick: () -> Unit, modifier: Modifier = Modifier, iconRotation: Float = 0f, - crouchProgress: Float = 0f, - iconLaunching: Boolean = false, - targetIconRotationDeg: Float = 0f, buttonScale: Float = 1f, ) { val theme = appTheme() @@ -69,12 +74,9 @@ fun PlayButton( Modifier } - // Icon rotation during crouch: 0° → targetIconRotationDeg as crouchProgress goes 0→1. - // `iconRotation` (orientation-based) is added on top so landscape still rotates the - // idle icon correctly. - val crouchRotation = targetIconRotationDeg * crouchProgress.coerceIn(0f, 1f) - // Icon scale during crouch: 1.0 → 0.9 - val crouchIconScale = 1f - 0.1f * crouchProgress.coerceIn(0f, 1f) + val description = stringResource( + if (isRunning) R.string.stop_timer else R.string.start_timer, + ) Box( modifier = modifier @@ -90,7 +92,8 @@ fun PlayButton( view.performHapticFeedback(playStopHaptic) } onClick() - }, + } + .semantics { contentDescription = description }, contentAlignment = Alignment.Center, ) { Canvas(modifier = Modifier.size(84.dp)) { @@ -113,25 +116,18 @@ fun PlayButton( Crossfade( targetState = isRunning, animationSpec = tween(durationMillis = 180), - label = "playIcon", + label = "stopIconFade", ) { running -> - val icon: ImageVector = if (running) Icons.Default.Stop else Icons.Default.PlayArrow - val desc = stringResource(if (running) R.string.stop_timer else R.string.start_timer) - // Play icon hides while the launch overlay is flying. - val iconAlpha = if (!running && iconLaunching) 0f else 1f - Icon( - imageVector = icon, - contentDescription = desc, - tint = theme.accentInk, - modifier = Modifier - .size(34.dp) - .graphicsLayer { - rotationZ = iconRotation + (if (!running) crouchRotation else 0f) - scaleX = if (!running) crouchIconScale else 1f - scaleY = if (!running) crouchIconScale else 1f - alpha = iconAlpha - }, - ) + if (running) { + Icon( + imageVector = Icons.Default.Stop, + contentDescription = null, + tint = theme.accentInk, + modifier = Modifier + .size(34.dp) + .graphicsLayer { rotationZ = iconRotation }, + ) + } } } } From e07429ef178d5bb6d23f4e587faf1e4743c8c816 Mon Sep 17 00:00:00 2001 From: Xitee1 <59659167+Xitee1@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:28:52 +0200 Subject: [PATCH 11/17] polish(timer-ui): add windup phase and punchier impact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launch is now two-stage: a 200ms windup where the icon hovers out of the button while scaling up (gathering energy), then a 340ms flight with aggressive ease-out to the dial. Was previously a single 420ms ease-in-out that felt too uniform. Impact extended from 260ms to 600ms so the three shockwave ripples have room to fully expand and fade (matching the prototype's 0/110/220ms stagger). Added a white "bang" flash that fires instantly at impact for a clearer moment of contact, with an accent-colored halo behind it as the longer-lasting glow. Ripples now stay contained within the dial's ring area (was overshooting past the edge), so the effect reads as a contained shockwave on the dial face. Icon fade-out window tightened from travel 0.85→1.0 to 0.95→1.0 so the icon disappears crisply at the moment the impact begins, removing the ~60ms visual gap between takeoff-fade and impact. --- .../timer/timer/components/CircularDial.kt | 56 +++++++++++++------ .../timer/timer/components/LaunchAnimation.kt | 37 +++++++----- 2 files changed, 61 insertions(+), 32 deletions(-) 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 b234dfb..180a992 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 @@ -396,12 +396,32 @@ private fun DrawScope.drawImpactEffects( pulse: Float, accent: Color, ) { - // 1) Center flash: hell-gefüllter Kreis vom Dial-Zentrum, der kurz aufflashed - // und dann ausklingt. Füllt die Innenfläche des Dials während des Impact-Peaks. + // 1) Weißer Flash-„Bang" — schneller heller Burst exakt beim Einschlag. Geht + // zuerst hoch, dann sofort wieder runter. Gibt dem Impact seinen Knall-Moment. + val bangRise = (pulse / 0.08f).coerceIn(0f, 1f) + val bangFall = ((1f - pulse) / 0.45f).coerceIn(0f, 1f).let { it * it } + val bangAlpha = bangRise * bangFall * 0.9f + if (bangAlpha > 0f) { + val bangRadius = 12.dp.toPx() + (ringRadius * 0.5f) * pulse.coerceAtMost(0.5f) * 2f + drawCircle( + brush = Brush.radialGradient( + 0f to Color.White.copy(alpha = bangAlpha), + 0.45f to Color.White.copy(alpha = bangAlpha * 0.5f), + 1f to Color.White.copy(alpha = 0f), + center = center, + radius = bangRadius, + ), + radius = bangRadius, + center = center, + ) + } + + // 2) Accent-Halo in der Dial-Innenfläche — länger anhaltender farbiger Glow + // hinter dem weißen Bang, fadet über die gesamte Impact-Phase aus. val flashInnerRadius = ringRadius - strokeWidth * 0.5f - 6.dp.toPx() - val flashEaseIn = (pulse / 0.2f).coerceIn(0f, 1f) - val flashEaseOut = ((1f - pulse) / 0.8f).coerceIn(0f, 1f) - val flashAlpha = (flashEaseIn * flashEaseOut) * 0.55f + val flashEaseIn = (pulse / 0.15f).coerceIn(0f, 1f) + val flashEaseOut = ((1f - pulse) / 0.85f).coerceIn(0f, 1f) + val flashAlpha = flashEaseIn * flashEaseOut * 0.55f if (flashAlpha > 0f) { drawCircle( brush = Brush.radialGradient( @@ -415,18 +435,19 @@ private fun DrawScope.drawImpactEffects( ) } - // 2) Drei konzentrische Shockwave-Ripples vom Dial-Zentrum, phase-shifted. Sie - // beginnen als dicker Ring nahe der Dial-Mitte und expandieren bis knapp über den - // äußeren Rand — der Puls „schwappt" also über das ganze Dial. - val rippleStart = ringRadius * 0.2f - val rippleEnd = ringRadius * 1.15f + // 3) Drei Shockwave-Ripples vom Dial-Zentrum. Wie Wasser-Wellen aus dem + // Einschlagspunkt — jeder Ring startet gestaffelt und läuft bis knapp an den + // inneren Ring-Rand. Bei 600ms Impact entsprechen die Offsets ungefähr den + // 0ms / 110ms / 220ms des Prototyps. + val rippleStart = 8.dp.toPx() + val rippleEnd = ringRadius * 0.95f for (offset in IMPACT_RIPPLE_OFFSETS) { val local = ((pulse - offset) / (1f - offset)).coerceIn(0f, 1f) if (local <= 0f) continue val rippleRadius = rippleStart + (rippleEnd - rippleStart) * local val rippleStroke = (6.dp.toPx() - 5.5f.dp.toPx() * local).coerceAtLeast(0.5f.dp.toPx()) - val rippleAlpha = (1f - local) * 0.8f + val rippleAlpha = (1f - local) * 0.85f drawCircle( color = accent.copy(alpha = rippleAlpha), radius = rippleRadius, @@ -435,12 +456,12 @@ private fun DrawScope.drawImpactEffects( ) } - // 3) Ring-Alpha-Boost: heller Overlay über dem bestehenden Progress-Arc. - // Schneller Anstieg (0→0.3), langsames Ausklingen (0.3→1.0). + // 4) Ring-Alpha-Boost: heller Overlay über dem Progress-Arc. + // Sehr schneller Anstieg (0→0.12), langsames Ausklingen. val boostAlpha = when { - pulse < 0.3f -> pulse / 0.3f - else -> (1f - pulse) / 0.7f - }.coerceIn(0f, 1f) * 0.4f + pulse < 0.12f -> pulse / 0.12f + else -> (1f - pulse) / 0.88f + }.coerceIn(0f, 1f) * 0.5f if (boostAlpha > 0f) { drawCircle( color = accent.copy(alpha = boostAlpha), @@ -451,4 +472,5 @@ private fun DrawScope.drawImpactEffects( } } -private val IMPACT_RIPPLE_OFFSETS = floatArrayOf(0f, 0.12f, 0.24f) +// Ripple-Offsets entsprechen ~0ms / 110ms / 220ms bei 600ms Impact (wie im Prototyp). +private val IMPACT_RIPPLE_OFFSETS = floatArrayOf(0f, 0.18f, 0.37f) 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 26c310d..1a253cb 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 @@ -70,32 +70,38 @@ class LaunchAnimationController(private val scope: CoroutineScope) { launch { iconScale.animateTo(0.9f, crouchSpec) } delay(140) - // Phase 2: Launch (140–560ms) + // Phase 2: Launch (140–680ms, 540ms total) — zweistufig: + // a) Windup (200ms): Icon schwebt kaum merklich aus dem Button, scaled auf, + // sammelt Energie („Vorbereitung" die der Nutzer vermisst hat). + // b) Flight (340ms): harter Schub zum Dial, Icon schrumpft perspektivisch. phase = LaunchPhase.Launch - val launchEasing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f) - val launchSpec = tween(420, easing = launchEasing) + val windupEasing = CubicBezierEasing(0.4f, 0f, 0.85f, 0f) + val flightEasing = CubicBezierEasing(0.15f, 0f, 0.3f, 1f) launch { buttonScale.animateTo(1f, tween(180)) } - launch { iconTravel.animateTo(1f, launchSpec) } - // Icon scale: 0.9 → 1.1 → 0.2 across the flight. launch { - iconScale.animateTo(1.1f, tween(120, easing = launchEasing)) - iconScale.animateTo(0.2f, tween(300, easing = launchEasing)) + iconTravel.animateTo(0.1f, tween(200, easing = windupEasing)) + iconTravel.animateTo(1f, tween(340, easing = flightEasing)) } - delay(420) + launch { + iconScale.animateTo(1.18f, tween(200, easing = windupEasing)) + iconScale.animateTo(0.25f, tween(340, easing = flightEasing)) + } + delay(540) - // Phase 3: Impact (560–820ms) + // Phase 3: Impact (680–1280ms, 600ms total). Länger als der Launch, damit die + // Shockwaves sich entfalten und ausklingen können. phase = LaunchPhase.Impact - val impactEasing = CubicBezierEasing(0.2f, 0.7f, 0.3f, 1f) - val impactSpec = tween(260, easing = impactEasing) + 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(130)) + buttonScale.animateTo(1f, tween(170)) } launch { impactPulse.animateTo(1f, impactSpec) } - delay(260) + delay(600) // Zurück auf Idle (snap, nicht animiert, weil nächstes Frame den echten Running-State hat). reset() @@ -154,9 +160,10 @@ fun LaunchOverlay( val travel = controller.iconTravel.value val iconScaleValue = controller.iconScale.value - // Fade-out kurz vor dem Impact, damit der Übergang zum Dial-Effekt weich wirkt. + // Crisper Fade-out unmittelbar vor dem Einschlag, damit Icon-Verschwinden und + // Dial-Impact als ein Moment wirken (vorher war eine sichtbare Lücke dazwischen). val alphaValue = - if (travel < 0.85f) 1f else (1f - (travel - 0.85f) / 0.15f).coerceIn(0f, 1f) + if (travel < 0.95f) 1f else (1f - (travel - 0.95f) / 0.05f).coerceIn(0f, 1f) // Glow wächst proportional zur Entfernung vom Button: auf dem Button fast unsichtbar, // im freien Flug deutlich sichtbar. val glowAlpha = travel.coerceIn(0f, 1f) * 0.9f From 71d5e8791a2a3577377e21e0b546ff9da59b1885 Mon Sep 17 00:00:00 2001 From: Xitee1 <59659167+Xitee1@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:39:28 +0200 Subject: [PATCH 12/17] feat(timer-ui): add rocket trail and move shockwaves to ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the v2 prototype after re-examining the reference. Three key visual elements were missing or wrong: 1. **Rocket trail** — the biggest gap. Renders a glowing white-to-accent line from the button center to the current icon position during the launch phase. Alpha ramps up to peak around mid-flight, fades out at the end. Without it, the icon just slides; with it, the launch reads as an actual rocket with an exhaust tail. 2. **Shockwaves originate at the ring, not the dial center**. The prototype draws the three concentric waves starting at r=ringRadius and expanding outward to ringRadius+~30dp, stroke thinning from 8dp to 0.5dp. Visually this reads as the progress ring itself radiating energy, not a ripple propagating out from some interior point. 3. **Icon scale curve matches the prototype keyframes**: 0.9 (end of crouch) → 1.1 at 30% of launch → 0.9 at 80% → 0.5 at impact, in three chained animateTo calls. Previous two-stage windup was too exaggerated (1.18 → 0.25) and the extra 120ms didn't help readability. Also reverted launch duration to 420ms (was 540) to match prototype exactly, tightened icon fade-out from 0.95→1.0 back to 0.8→1.0, and replaced the accent-halo dial fill with a more prominent white impact flash (6dp → 80dp, alpha fading over ~380ms). --- .../timer/timer/components/CircularDial.kt | 55 +++++--------- .../timer/timer/components/LaunchAnimation.kt | 72 +++++++++++++------ 2 files changed, 69 insertions(+), 58 deletions(-) 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 180a992..08d528f 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 @@ -396,17 +396,16 @@ private fun DrawScope.drawImpactEffects( pulse: Float, accent: Color, ) { - // 1) Weißer Flash-„Bang" — schneller heller Burst exakt beim Einschlag. Geht - // zuerst hoch, dann sofort wieder runter. Gibt dem Impact seinen Knall-Moment. - val bangRise = (pulse / 0.08f).coerceIn(0f, 1f) - val bangFall = ((1f - pulse) / 0.45f).coerceIn(0f, 1f).let { it * it } - val bangAlpha = bangRise * bangFall * 0.9f + // 1) Weißer Flash-„Bang" vom Dial-Zentrum — kurz, hell, weitet sich auf ~80dp. + // 1:1 nach Prototyp `impactFlash`: r 6dp → 80dp, opacity 0.95 → 0. + val bangProgress = (pulse / 0.63f).coerceIn(0f, 1f) // expansion completes at ~380ms of 600ms + val bangRadius = 6.dp.toPx() + (80.dp.toPx() - 6.dp.toPx()) * bangProgress + val bangAlpha = (1f - bangProgress).coerceAtLeast(0f) * 0.95f if (bangAlpha > 0f) { - val bangRadius = 12.dp.toPx() + (ringRadius * 0.5f) * pulse.coerceAtMost(0.5f) * 2f drawCircle( brush = Brush.radialGradient( 0f to Color.White.copy(alpha = bangAlpha), - 0.45f to Color.White.copy(alpha = bangAlpha * 0.5f), + 0.5f to Color.White.copy(alpha = bangAlpha * 0.45f), 1f to Color.White.copy(alpha = 0f), center = center, radius = bangRadius, @@ -416,38 +415,20 @@ private fun DrawScope.drawImpactEffects( ) } - // 2) Accent-Halo in der Dial-Innenfläche — länger anhaltender farbiger Glow - // hinter dem weißen Bang, fadet über die gesamte Impact-Phase aus. - val flashInnerRadius = ringRadius - strokeWidth * 0.5f - 6.dp.toPx() - val flashEaseIn = (pulse / 0.15f).coerceIn(0f, 1f) - val flashEaseOut = ((1f - pulse) / 0.85f).coerceIn(0f, 1f) - val flashAlpha = flashEaseIn * flashEaseOut * 0.55f - if (flashAlpha > 0f) { - drawCircle( - brush = Brush.radialGradient( - 0f to accent.copy(alpha = flashAlpha), - 1f to accent.copy(alpha = 0f), - center = center, - radius = flashInnerRadius, - ), - radius = flashInnerRadius, - center = center, - ) - } - - // 3) Drei Shockwave-Ripples vom Dial-Zentrum. Wie Wasser-Wellen aus dem - // Einschlagspunkt — jeder Ring startet gestaffelt und läuft bis knapp an den - // inneren Ring-Rand. Bei 600ms Impact entsprechen die Offsets ungefähr den - // 0ms / 110ms / 220ms des Prototyps. - val rippleStart = 8.dp.toPx() - val rippleEnd = ringRadius * 0.95f + // 2) Drei Shockwave-Ringe, die vom Progress-Ring nach außen schwappen — wie + // wenn der Ring selbst Energie abgibt. 1:1 nach Prototyp: r 130 → 210 (= +62% + // über ringRadius), Stroke schrumpft von ~8 auf ~0.5dp während der Expansion. + // Offsets ~0ms/110ms/220ms bei 600ms Impact. + val rippleStart = ringRadius + val rippleEnd = ringRadius + 32.dp.toPx() + val rippleStrokeStart = 8.dp.toPx() + val rippleStrokeEnd = 0.5f.dp.toPx() for (offset in IMPACT_RIPPLE_OFFSETS) { val local = ((pulse - offset) / (1f - offset)).coerceIn(0f, 1f) if (local <= 0f) continue val rippleRadius = rippleStart + (rippleEnd - rippleStart) * local - val rippleStroke = - (6.dp.toPx() - 5.5f.dp.toPx() * local).coerceAtLeast(0.5f.dp.toPx()) - val rippleAlpha = (1f - local) * 0.85f + val rippleStroke = rippleStrokeStart + (rippleStrokeEnd - rippleStrokeStart) * local + val rippleAlpha = (1f - local) * 0.9f drawCircle( color = accent.copy(alpha = rippleAlpha), radius = rippleRadius, @@ -456,8 +437,8 @@ private fun DrawScope.drawImpactEffects( ) } - // 4) Ring-Alpha-Boost: heller Overlay über dem Progress-Arc. - // Sehr schneller Anstieg (0→0.12), langsames Ausklingen. + // 3) Ring-Glow-Boost: das Outer-Glow-Feld um den Progress-Ring intensiviert + // sich kurz („igniting"-Phase). Schneller Anstieg, langsames Ausklingen. val boostAlpha = when { pulse < 0.12f -> pulse / 0.12f else -> (1f - pulse) / 0.88f 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 1a253cb..e6cd03d 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 @@ -5,6 +5,7 @@ import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow @@ -20,6 +21,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset 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 kotlinx.coroutines.CoroutineScope @@ -70,26 +72,25 @@ class LaunchAnimationController(private val scope: CoroutineScope) { launch { iconScale.animateTo(0.9f, crouchSpec) } delay(140) - // Phase 2: Launch (140–680ms, 540ms total) — zweistufig: - // a) Windup (200ms): Icon schwebt kaum merklich aus dem Button, scaled auf, - // sammelt Energie („Vorbereitung" die der Nutzer vermisst hat). - // b) Flight (340ms): harter Schub zum Dial, Icon schrumpft perspektivisch. + // Phase 2: Launch (140–560ms, 420ms) — 1:1 nach Prototyp. Scale-Kurve + // 1.0 → 1.1 (30%) → 0.9 (80%) → 0.5 (100%): kurzer Windup beim Abheben, + // dann perspektivische Verkleinerung zur Dial-Mitte. Travel-Kurve kommt + // aus der Material-Easing (langsamer Start, schnelle Mitte, weiches Ende). phase = LaunchPhase.Launch - val windupEasing = CubicBezierEasing(0.4f, 0f, 0.85f, 0f) - val flightEasing = CubicBezierEasing(0.15f, 0f, 0.3f, 1f) + val launchEasing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f) launch { buttonScale.animateTo(1f, tween(180)) } + launch { iconTravel.animateTo(1f, tween(420, easing = launchEasing)) } launch { - iconTravel.animateTo(0.1f, tween(200, easing = windupEasing)) - iconTravel.animateTo(1f, tween(340, easing = flightEasing)) + // Start ist 0.9 (vom Crouch). 3-stufig matched die Prototyp-Keyframes. + iconScale.animateTo(1.1f, tween(126, easing = launchEasing)) + iconScale.animateTo(0.9f, tween(210, easing = launchEasing)) + iconScale.animateTo(0.5f, tween(84, easing = launchEasing)) } - launch { - iconScale.animateTo(1.18f, tween(200, easing = windupEasing)) - iconScale.animateTo(0.25f, tween(340, easing = flightEasing)) - } - delay(540) + delay(420) - // Phase 3: Impact (680–1280ms, 600ms total). Länger als der Launch, damit die - // Shockwaves sich entfalten und ausklingen können. + // Phase 3: Impact (560–1160ms, 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) @@ -160,18 +161,47 @@ fun LaunchOverlay( val travel = controller.iconTravel.value val iconScaleValue = controller.iconScale.value - // Crisper Fade-out unmittelbar vor dem Einschlag, damit Icon-Verschwinden und - // Dial-Impact als ein Moment wirken (vorher war eine sichtbare Lücke dazwischen). + // Icon fadet über die letzten 20% des Fluges aus — matched den Prototyp. val alphaValue = - if (travel < 0.95f) 1f else (1f - (travel - 0.95f) / 0.05f).coerceIn(0f, 1f) - // Glow wächst proportional zur Entfernung vom Button: auf dem Button fast unsichtbar, - // im freien Flug deutlich sichtbar. + 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 - // Container-Box ist 60dp (Glow-Radius). Icon ist 34dp zentriert darin. + // 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, + ), + start = buttonCenter, + end = trailHead, + strokeWidth = 10.dp.toPx(), + cap = StrokeCap.Round, + ) + } + } + } + + // Icon + runder Accent-Glow direkt dahinter. Container ist 60dp, Icon 34dp. Box( modifier = Modifier .size(60.dp) From e10f1daed9456614b3fad31622512619040b27f0 Mon Sep 17 00:00:00 2001 From: Xitee1 <59659167+Xitee1@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:48:12 +0200 Subject: [PATCH 13/17] polish(timer-ui): split rocket travel into three eased segments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iconScale was already keyframed per the prototype, but iconTravel was a single 420ms tween — smooth continuous acceleration with no dramatic pacing. The prototype's rocketShoot CSS keyframe bakes in three segment boundaries (30% / 80% of duration) and applies the easing per-segment, which makes velocity drop to near zero at each boundary. Splitting iconTravel into the same three segments produces that effect: a short "hang" right after the icon leaves the button (~126ms mark), a fast middle flight, and a soft approach to the dial. The pause-then-burst rhythm is where the "power" in the launch actually comes from. --- .../timer/timer/components/LaunchAnimation.kt | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) 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 e6cd03d..d910ae5 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 @@ -72,16 +72,31 @@ class LaunchAnimationController(private val scope: CoroutineScope) { launch { iconScale.animateTo(0.9f, crouchSpec) } delay(140) - // Phase 2: Launch (140–560ms, 420ms) — 1:1 nach Prototyp. Scale-Kurve - // 1.0 → 1.1 (30%) → 0.9 (80%) → 0.5 (100%): kurzer Windup beim Abheben, - // dann perspektivische Verkleinerung zur Dial-Mitte. Travel-Kurve kommt - // aus der Material-Easing (langsamer Start, schnelle Mitte, weiches Ende). + // Phase 2: Launch (140–560ms, 420ms) — 1:1 nach Prototyp-Keyframes. + // + // Travel ist in DREI Abschnitte gesplittet, jeder separat ease-in-out — + // das ist entscheidend: ein einzelner Tween über 420ms fühlt sich wie + // gleichmäßige Beschleunigung an, aber mit drei Segmenten fällt die + // Geschwindigkeit an den Grenzen (30% / 80% der Zeit) auf nahezu Null. + // Genau diese kurzen Pausen — besonders die nach dem Verlassen des + // Buttons bei 30% — geben der Animation ihren „Power"-Charakter: + // Segment 1 (0–126ms): 0 → 0.28 — Icon hebt ab, nimmt dabei Fahrt auf, + // verliert sie zum Ende wieder (*Hang nach Launch*). + // Segment 2 (126–336ms): 0.28 → 0.84 — Hauptflug, schnellster Abschnitt. + // Segment 3 (336–420ms): 0.84 → 1.0 — weiches Einschlag-Pacing. phase = LaunchPhase.Launch val launchEasing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f) launch { buttonScale.animateTo(1f, tween(180)) } - launch { iconTravel.animateTo(1f, tween(420, easing = launchEasing)) } launch { - // Start ist 0.9 (vom Crouch). 3-stufig matched die Prototyp-Keyframes. + iconTravel.animateTo(0.28f, tween(126, easing = launchEasing)) + iconTravel.animateTo(0.84f, tween(210, easing = launchEasing)) + iconTravel.animateTo(1f, tween(84, easing = launchEasing)) + } + launch { + // Scale: 0.9 (vom Crouch) → 1.1 → 0.9 → 0.5 mit denselben Segment- + // Grenzen wie Travel. 0.9→1.1 fühlt sich an wie eine Feder die sich + // entspannt; 1.1→0.9 ist die „Aufrichtung"; 0.9→0.5 perspektivische + // Verkleinerung beim Zudringen aufs Dial. iconScale.animateTo(1.1f, tween(126, easing = launchEasing)) iconScale.animateTo(0.9f, tween(210, easing = launchEasing)) iconScale.animateTo(0.5f, tween(84, easing = launchEasing)) From 0c0b687a44674cf19d2bcfcbe652b7dde060012a Mon Sep 17 00:00:00 2001 From: Xitee1 <59659167+Xitee1@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:58:39 +0200 Subject: [PATCH 14/17] polish(timer-ui): smooth speed transitions and hide stop icon during launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tweaks after another test pass: 1. **Travel/scale use a single keyframes spec with linear interpolation** between waypoints instead of three chained animateTo calls. Chained tweens each decelerate to zero at their end, producing hard stops at the 30% and 80% segment boundaries. Linear-per-segment keeps a constant velocity inside each segment and only shifts speed at the boundary — still dynamic (ratios 1:1.7:1.1 for travel) but without the "stopping" feel. The waypoints themselves are unchanged so the overall keyframe shape still matches the prototype. 2. **Stop icon no longer appears mid-launch.** The PlayButton's crossfade was keyed on `isRunning`, which becomes true as soon as the service starts — typically during the launch phase, 50-100ms after tap. That made it look like a *second* arrow popped out of the button behind the flying one. Now the TimerScreen derives a separate `playButtonShowsRunning = isRunning && phase == Idle` and passes it to the PlayButton; the visual state only flips to "running" after the controller has fully finished the animation and reset to Idle. --- .../feature/timer/timer/TimerScreen.kt | 11 +++- .../timer/timer/components/LaunchAnimation.kt | 53 +++++++++++-------- 2 files changed, 41 insertions(+), 23 deletions(-) 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 0d5646f..55bf842 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 @@ -359,6 +359,13 @@ 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. + 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 @@ -371,6 +378,7 @@ private fun TimerContent( ActionRow( isRunning = isRunning, + playButtonShowsRunning = playButtonShowsRunning, hapticEnabled = settings.hapticFeedbackEnabled, iconRotation = animatedAngle, onToggle = { @@ -536,6 +544,7 @@ private fun HomeTopBar( @Composable private fun ActionRow( isRunning: Boolean, + playButtonShowsRunning: Boolean, hapticEnabled: Boolean, iconRotation: Float, onToggle: () -> Unit, @@ -563,7 +572,7 @@ private fun ActionRow( iconRotation = iconRotation, ) PlayButton( - isRunning = isRunning, + isRunning = playButtonShowsRunning, hapticEnabled = hapticEnabled, onClick = onToggle, iconRotation = iconRotation, 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 d910ae5..a2b086a 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,8 @@ 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.LinearEasing +import androidx.compose.animation.core.keyframes import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Box @@ -72,34 +74,41 @@ class LaunchAnimationController(private val scope: CoroutineScope) { launch { iconScale.animateTo(0.9f, crouchSpec) } delay(140) - // Phase 2: Launch (140–560ms, 420ms) — 1:1 nach Prototyp-Keyframes. + // Phase 2: Launch (140–560ms, 420ms). // - // Travel ist in DREI Abschnitte gesplittet, jeder separat ease-in-out — - // das ist entscheidend: ein einzelner Tween über 420ms fühlt sich wie - // gleichmäßige Beschleunigung an, aber mit drei Segmenten fällt die - // Geschwindigkeit an den Grenzen (30% / 80% der Zeit) auf nahezu Null. - // Genau diese kurzen Pausen — besonders die nach dem Verlassen des - // Buttons bei 30% — geben der Animation ihren „Power"-Charakter: - // Segment 1 (0–126ms): 0 → 0.28 — Icon hebt ab, nimmt dabei Fahrt auf, - // verliert sie zum Ende wieder (*Hang nach Launch*). - // Segment 2 (126–336ms): 0.28 → 0.84 — Hauptflug, schnellster Abschnitt. - // Segment 3 (336–420ms): 0.84 → 1.0 — weiches Einschlag-Pacing. + // Travel und Scale laufen beide als SINGLE keyframes-Animation mit linearer + // Interpolation zwischen den Wegpunkten. Ergebnis: innerhalb jedes Segments + // ist die Geschwindigkeit konstant, an den Segment-Grenzen (30% und 80% der + // Zeit) springt sie auf einen anderen Wert — ohne die Deceleration-to-zero + // die bei einer Kette von animateTo-Calls entstehen würde (und die sich + // wie harte Stops anfühlen). Speed-Verhältnisse Travel: 1.0 : 1.7 : 1.1 + // — deutlich spürbar dynamisch. + // + // Wegpunkte sind dieselben wie im Prototyp (rocketShoot-Keyframes): 0 → + // 0.22 (30%) → 0.84 (80%) → 1.0 (100%) für Travel, 0.9 → 1.1 → 0.9 → 0.5 + // für Scale. 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)) + iconTravel.animateTo( + targetValue = 1f, + animationSpec = keyframes { + durationMillis = 420 + 0.22f at 126 using LinearEasing + 0.84f at 336 using LinearEasing + // Letztes Segment 0.84→1.0 in 84ms läuft mit Default-Easing. + }, + ) } launch { - // Scale: 0.9 (vom Crouch) → 1.1 → 0.9 → 0.5 mit denselben Segment- - // Grenzen wie Travel. 0.9→1.1 fühlt sich an wie eine Feder die sich - // entspannt; 1.1→0.9 ist die „Aufrichtung"; 0.9→0.5 perspektivische - // Verkleinerung beim Zudringen aufs Dial. - iconScale.animateTo(1.1f, tween(126, easing = launchEasing)) - iconScale.animateTo(0.9f, tween(210, easing = launchEasing)) - iconScale.animateTo(0.5f, tween(84, easing = launchEasing)) + iconScale.animateTo( + targetValue = 0.5f, + animationSpec = keyframes { + durationMillis = 420 + 1.1f at 126 using LinearEasing + 0.9f at 336 using LinearEasing + }, + ) } delay(420) From 3e130ccab6c9db2c9d0c9e5050aefbd30de11f1b Mon Sep 17 00:00:00 2001 From: Xitee1 <59659167+Xitee1@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:09:48 +0200 Subject: [PATCH 15/17] polish(timer-ui): add explicit hold between lift-off and cruise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linear-only per-segment was too smooth — the pause after the button washed out. Ease-in-out per-segment was too hard — hit a wall at each boundary. Sweet spot: a short explicit hold. The launch timeline now has four beats: - 0-100ms: ease-in-out lift to 0.22 (decelerates into the hold) - 100-135ms: 35ms hold at 0.22 / scale 1.1 — the visible "hang" right after the icon leaves the button, where it reads as gathering energy - 135-336ms: linear cruise at higher constant velocity (big jump from zero gives the acceleration its "power" moment) - 336-420ms: linear approach to impact at the dial Scale follows the same beats so the icon stays at peak size (1.1) during the hold, then shrinks perspectively during the cruise. Total duration unchanged at 420ms. --- .../timer/timer/components/LaunchAnimation.kt | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) 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 a2b086a..691808b 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.LinearEasing import androidx.compose.animation.core.keyframes import androidx.compose.animation.core.tween @@ -76,17 +77,18 @@ class LaunchAnimationController(private val scope: CoroutineScope) { // Phase 2: Launch (140–560ms, 420ms). // - // Travel und Scale laufen beide als SINGLE keyframes-Animation mit linearer - // Interpolation zwischen den Wegpunkten. Ergebnis: innerhalb jedes Segments - // ist die Geschwindigkeit konstant, an den Segment-Grenzen (30% und 80% der - // Zeit) springt sie auf einen anderen Wert — ohne die Deceleration-to-zero - // die bei einer Kette von animateTo-Calls entstehen würde (und die sich - // wie harte Stops anfühlen). Speed-Verhältnisse Travel: 1.0 : 1.7 : 1.1 - // — deutlich spürbar dynamisch. + // Pacing in vier Teilen: + // 1. Lift-off (0–100ms): ease-in-out, Icon hebt ab auf 22% Strecke. + // Decceleriert am Ende → Velocity nahe Null. + // 2. Hold (100–135ms): 35ms Standstill auf 22% — der „Hang" direkt nach + // dem Verlassen des Buttons, sichtbar aber kurz. + // 3. Cruise (135–336ms): linearer, schneller Flug zu 84% (spürbar + // höhere Konstantgeschwindigkeit als in Phase 1). + // 4. Approach (336–420ms): linearer Rest zum Einschlag auf 100%. // - // Wegpunkte sind dieselben wie im Prototyp (rocketShoot-Keyframes): 0 → - // 0.22 (30%) → 0.84 (80%) → 1.0 (100%) für Travel, 0.9 → 1.1 → 0.9 → 0.5 - // für Scale. + // Der Hold erzeugt die Pause ohne die „Vollbremsung"-Qualität einer + // geketteten ease-in-out Sequenz. Danach springt die Geschwindigkeit + // abrupt auf Cruise-Speed — das gibt der Launch-Phase ihren Power-Burst. phase = LaunchPhase.Launch launch { buttonScale.animateTo(1f, tween(180)) } launch { @@ -94,9 +96,9 @@ class LaunchAnimationController(private val scope: CoroutineScope) { targetValue = 1f, animationSpec = keyframes { durationMillis = 420 - 0.22f at 126 using LinearEasing + 0.22f at 100 using FastOutSlowInEasing + 0.22f at 135 using LinearEasing 0.84f at 336 using LinearEasing - // Letztes Segment 0.84→1.0 in 84ms läuft mit Default-Easing. }, ) } @@ -105,7 +107,10 @@ class LaunchAnimationController(private val scope: CoroutineScope) { targetValue = 0.5f, animationSpec = keyframes { durationMillis = 420 - 1.1f at 126 using LinearEasing + // Scale peaked at 1.1 während Lift-off, hält durch den Hang, + // schrumpft dann im Cruise auf 0.9 und am Ende perspektivisch auf 0.5. + 1.1f at 100 using FastOutSlowInEasing + 1.1f at 135 using LinearEasing 0.9f at 336 using LinearEasing }, ) From 08db9db726ccdaf8f532019e2e448ebbb357de33 Mon Sep 17 00:00:00 2001 From: Xitee1 <59659167+Xitee1@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:13:42 +0200 Subject: [PATCH 16/17] revert(timer-ui): restore 3-chained ease-in-out pacing for launch User preferred the pacing from e10f1da (three chained animateTo calls with per-segment ease-in-out). The subsequent "smoother" and "explicit hold" experiments both felt worse. Reverting just the travel/scale specs; the stop-icon-after-animation fix from 0c0b687 stays. --- .../timer/timer/components/LaunchAnimation.kt | 49 +++++-------------- 1 file changed, 12 insertions(+), 37 deletions(-) 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 691808b..0ec32ac 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,9 +2,6 @@ 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.LinearEasing -import androidx.compose.animation.core.keyframes import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Box @@ -75,45 +72,23 @@ class LaunchAnimationController(private val scope: CoroutineScope) { launch { iconScale.animateTo(0.9f, crouchSpec) } delay(140) - // Phase 2: Launch (140–560ms, 420ms). - // - // Pacing in vier Teilen: - // 1. Lift-off (0–100ms): ease-in-out, Icon hebt ab auf 22% Strecke. - // Decceleriert am Ende → Velocity nahe Null. - // 2. Hold (100–135ms): 35ms Standstill auf 22% — der „Hang" direkt nach - // dem Verlassen des Buttons, sichtbar aber kurz. - // 3. Cruise (135–336ms): linearer, schneller Flug zu 84% (spürbar - // höhere Konstantgeschwindigkeit als in Phase 1). - // 4. Approach (336–420ms): linearer Rest zum Einschlag auf 100%. - // - // Der Hold erzeugt die Pause ohne die „Vollbremsung"-Qualität einer - // geketteten ease-in-out Sequenz. Danach springt die Geschwindigkeit - // abrupt auf Cruise-Speed — das gibt der Launch-Phase ihren Power-Burst. + // 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%), + // 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( - targetValue = 1f, - animationSpec = keyframes { - durationMillis = 420 - 0.22f at 100 using FastOutSlowInEasing - 0.22f at 135 using LinearEasing - 0.84f at 336 using LinearEasing - }, - ) + 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( - targetValue = 0.5f, - animationSpec = keyframes { - durationMillis = 420 - // Scale peaked at 1.1 während Lift-off, hält durch den Hang, - // schrumpft dann im Cruise auf 0.9 und am Ende perspektivisch auf 0.5. - 1.1f at 100 using FastOutSlowInEasing - 1.1f at 135 using LinearEasing - 0.9f at 336 using LinearEasing - }, - ) + iconScale.animateTo(1.1f, tween(126, easing = launchEasing)) + iconScale.animateTo(0.9f, tween(210, easing = launchEasing)) + iconScale.animateTo(0.5f, tween(84, easing = launchEasing)) } delay(420) From e1105016e8864b36949f9d732a723ea122fcaa2f Mon Sep 17 00:00:00 2001 From: Xitee1 <59659167+Xitee1@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:14:29 +0200 Subject: [PATCH 17/17] docs: add launch animation design spec and implementation plan Reference artifacts produced during the feature's brainstorming/planning phase: the design decisions (scope, UX, edge cases) and the task breakdown that guided implementation. --- .../plans/2026-04-20-launch-animation.md | 1226 +++++++++++++++++ .../2026-04-20-launch-animation-design.md | 211 +++ 2 files changed, 1437 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-20-launch-animation.md create mode 100644 docs/superpowers/specs/2026-04-20-launch-animation-design.md diff --git a/docs/superpowers/plans/2026-04-20-launch-animation.md b/docs/superpowers/plans/2026-04-20-launch-animation.md new file mode 100644 index 0000000..0a40259 --- /dev/null +++ b/docs/superpowers/plans/2026-04-20-launch-animation.md @@ -0,0 +1,1226 @@ +# Play-Button Launch-Animation Implementation Plan + +> **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. + +**Architecture:** Ein neuer `LaunchAnimationController` (Animatable-basierte State-Machine in einer Coroutine) orchestriert die Phasen Crouch → Launch → Impact sequenziell. Ein `LaunchOverlay` Composable rendert das fliegende Icon im Root-Koordinatenraum des `TimerScreen`. `PlayButton` und `CircularDial` bekommen neue optionale Parameter, werden aber nicht anderweitig umgebaut — die Komponenten bleiben dumb, die Orchestrierung sitzt in `TimerScreen`. + +**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` + +**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. + +**Build commands for reference:** +- Vollbuild: `./gradlew assembleDebug` +- Modul-Lint: `./gradlew :feature:timer:lintDebug`, `./gradlew :core:data:lintDebug` +- Kompletter Lint: `./gradlew lint` +- Release-Sanity: `./gradlew assembleRelease` (fällt ohne Keystore auf Debug-Signing zurück) + +--- + +## File Structure + +**Neue Dateien:** +- `core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/util/ReduceMotion.kt` — Utility zum Lesen des Android-System-Reduce-Motion-Flags +- `feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/LaunchAnimation.kt` — `LaunchPhase`, `LaunchAnimationController`, `rememberLaunchAnimationController`, `LaunchOverlay` + +**Geänderte Dateien:** +- `core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/UserSettings.kt` — neues Feld +- `core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepository.kt` — neue `update`-Methode +- `core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepositoryImpl.kt` — Key, Seeding, Flow-Mapping, Update +- `feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsViewModel.kt` — Handler +- `feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsScreen.kt` — Toggle-Row +- `feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/PlayButton.kt` — neue Parameter +- `feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/CircularDial.kt` — neuer Parameter + Impact-Rendering +- `feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerScreen.kt` — Controller, Position-Tracking, Trigger, Overlay +- `feature/timer/src/main/res/values/strings.xml` — neue Strings +- `feature/timer/src/main/res/values-de/strings.xml` — deutsche Übersetzung + +--- + +## Task 1: Reduce-Motion-Utility anlegen + +**Files:** +- Create: `core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/util/ReduceMotion.kt` + +- [ ] **Step 1: Datei erstellen** + +```kotlin +package dev.xitee.sleeptimer.core.data.util + +import android.content.Context +import android.provider.Settings + +/** + * Gibt true zurück, wenn der Nutzer in den System-Einstellungen „Animationen entfernen" + * aktiviert hat. Erkennung über `Settings.Global.ANIMATOR_DURATION_SCALE == 0f`, was + * von Accessibility-Settings und den Developer-Options identisch gesetzt wird. + */ +fun isSystemReduceMotionEnabled(context: Context): Boolean { + val scale = Settings.Global.getFloat( + context.contentResolver, + Settings.Global.ANIMATOR_DURATION_SCALE, + 1f, + ) + return scale == 0f +} +``` + +- [ ] **Step 2: Build verifizieren** + +Run: `./gradlew :core:data:assembleDebug` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: Commit** + +```bash +git add core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/util/ReduceMotion.kt +git commit -m "feat(core-data): add system reduce-motion detection utility" +``` + +--- + +## Task 2: `launchAnimationEnabled`-Feld in `UserSettings` + +**Files:** +- Modify: `core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/UserSettings.kt` + +- [ ] **Step 1: Feld hinzufügen** + +Ersetze den gesamten Inhalt der Datei durch: + +```kotlin +package dev.xitee.sleeptimer.core.data.model + +data class UserSettings( + val stopMediaPlayback: Boolean = true, + val fadeOutDurationSeconds: Int = 30, + val screenOff: Boolean = false, + val softScreenOff: Boolean = false, + val turnOffWifi: Boolean = false, + val turnOffBluetooth: Boolean = false, + val hapticFeedbackEnabled: Boolean = true, + val theme: ThemeId = ThemeId.Default, + val starsEnabled: Boolean = true, + val stepMinutes: Int = 5, + val presetMinutes: Int = 15, + val launchAnimationEnabled: Boolean = true, +) +``` + +- [ ] **Step 2: Build verifizieren** + +Run: `./gradlew :core:data:assembleDebug` +Expected: BUILD SUCCESSFUL + +--- + +## Task 3: `SettingsRepository`-Interface erweitern + +**Files:** +- Modify: `core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepository.kt` + +- [ ] **Step 1: Neue Methode zum Interface hinzufügen** + +Ersetze den gesamten Inhalt durch: + +```kotlin +package dev.xitee.sleeptimer.core.data.repository + +import dev.xitee.sleeptimer.core.data.model.ThemeId +import dev.xitee.sleeptimer.core.data.model.UserSettings +import kotlinx.coroutines.flow.Flow + +interface SettingsRepository { + val settings: Flow + suspend fun updateStopMediaPlayback(enabled: Boolean) + suspend fun updateFadeOutDuration(seconds: Int) + suspend fun updateScreenOff(enabled: Boolean) + suspend fun updateSoftScreenOff(enabled: Boolean) + suspend fun updateTurnOffWifi(enabled: Boolean) + suspend fun updateTurnOffBluetooth(enabled: Boolean) + suspend fun updateHapticFeedback(enabled: Boolean) + suspend fun updateTheme(theme: ThemeId) + suspend fun updateStarsEnabled(enabled: Boolean) + suspend fun updateStepMinutes(minutes: Int) + suspend fun updatePresetMinutes(minutes: Int) + suspend fun updateLaunchAnimationEnabled(enabled: Boolean) +} +``` + +- [ ] **Step 2: Build fails (Impl implementiert neue Methode noch nicht)** + +Run: `./gradlew :core:data:assembleDebug` +Expected: FAIL — `Class SettingsRepositoryImpl is not abstract and does not implement abstract member public abstract suspend fun updateLaunchAnimationEnabled(...)` + +Dies bestätigt, dass das Interface korrekt erweitert wurde. Task 4 behebt den Build. + +--- + +## Task 4: `SettingsRepositoryImpl` erweitern (Key, Seeding, Flow, Update) + +**Files:** +- Modify: `core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepositoryImpl.kt` + +**Kontext:** Das Setting soll beim allerersten App-Start einmalig basierend auf dem System-Reduce-Motion-Flag persistiert werden. Dafür gibt es einen zweiten „seeded"-Boolean-Key, der nach dem ersten Seeding auf `true` gesetzt wird. Ohne diesen Flag würden Änderungen des System-Settings nach dem Install das App-Setting bei jedem Read neu berechnen. + +- [ ] **Step 1: Impl komplett ersetzen** + +Ersetze den gesamten Inhalt durch: + +```kotlin +package dev.xitee.sleeptimer.core.data.repository + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import dagger.hilt.android.qualifiers.ApplicationContext +import dev.xitee.sleeptimer.core.data.model.ThemeId +import dev.xitee.sleeptimer.core.data.model.UserSettings +import dev.xitee.sleeptimer.core.data.util.isSystemReduceMotionEnabled +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SettingsRepositoryImpl @Inject constructor( + private val dataStore: DataStore, + @ApplicationContext private val context: Context, +) : SettingsRepository { + + private companion object { + val STOP_MEDIA = booleanPreferencesKey("stop_media_playback") + val FADE_OUT_DURATION = intPreferencesKey("fade_out_duration_seconds") + val SCREEN_OFF = booleanPreferencesKey("screen_off") + val SOFT_SCREEN_OFF = booleanPreferencesKey("soft_screen_off") + val TURN_OFF_WIFI = booleanPreferencesKey("turn_off_wifi") + val TURN_OFF_BLUETOOTH = booleanPreferencesKey("turn_off_bluetooth") + val HAPTIC_FEEDBACK = booleanPreferencesKey("haptic_feedback") + val THEME = stringPreferencesKey("theme") + val STARS_ENABLED = booleanPreferencesKey("stars_enabled") + 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. + 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). + initScope.launch { + dataStore.edit { prefs -> + if (prefs[LAUNCH_ANIMATION_SEEDED] != true) { + prefs[LAUNCH_ANIMATION_ENABLED] = !isSystemReduceMotionEnabled(context) + prefs[LAUNCH_ANIMATION_SEEDED] = true + } + } + } + } + + override val settings: Flow = dataStore.data.map { prefs -> + // Single source of truth: defaults come from UserSettings(), so adding a new + // field only requires updating the data class. + val d = UserSettings() + UserSettings( + stopMediaPlayback = prefs[STOP_MEDIA] ?: d.stopMediaPlayback, + fadeOutDurationSeconds = prefs[FADE_OUT_DURATION] ?: d.fadeOutDurationSeconds, + screenOff = prefs[SCREEN_OFF] ?: d.screenOff, + softScreenOff = prefs[SOFT_SCREEN_OFF] ?: d.softScreenOff, + turnOffWifi = prefs[TURN_OFF_WIFI] ?: d.turnOffWifi, + turnOffBluetooth = prefs[TURN_OFF_BLUETOOTH] ?: d.turnOffBluetooth, + hapticFeedbackEnabled = prefs[HAPTIC_FEEDBACK] ?: d.hapticFeedbackEnabled, + theme = ThemeId.fromStorage(prefs[THEME]), + starsEnabled = prefs[STARS_ENABLED] ?: d.starsEnabled, + stepMinutes = prefs[STEP_MINUTES] ?: d.stepMinutes, + presetMinutes = prefs[PRESET_MINUTES] ?: d.presetMinutes, + launchAnimationEnabled = prefs[LAUNCH_ANIMATION_ENABLED] ?: d.launchAnimationEnabled, + ) + } + + override suspend fun updateStopMediaPlayback(enabled: Boolean) { + dataStore.edit { it[STOP_MEDIA] = enabled } + } + + override suspend fun updateFadeOutDuration(seconds: Int) { + dataStore.edit { it[FADE_OUT_DURATION] = seconds } + } + + override suspend fun updateScreenOff(enabled: Boolean) { + dataStore.edit { it[SCREEN_OFF] = enabled } + } + + override suspend fun updateSoftScreenOff(enabled: Boolean) { + dataStore.edit { it[SOFT_SCREEN_OFF] = enabled } + } + + override suspend fun updateTurnOffWifi(enabled: Boolean) { + dataStore.edit { it[TURN_OFF_WIFI] = enabled } + } + + override suspend fun updateTurnOffBluetooth(enabled: Boolean) { + dataStore.edit { it[TURN_OFF_BLUETOOTH] = enabled } + } + + override suspend fun updateHapticFeedback(enabled: Boolean) { + dataStore.edit { it[HAPTIC_FEEDBACK] = enabled } + } + + override suspend fun updateTheme(theme: ThemeId) { + dataStore.edit { it[THEME] = theme.name } + } + + override suspend fun updateStarsEnabled(enabled: Boolean) { + dataStore.edit { it[STARS_ENABLED] = enabled } + } + + override suspend fun updateStepMinutes(minutes: Int) { + dataStore.edit { it[STEP_MINUTES] = minutes.coerceIn(1, 30) } + } + + override suspend fun updatePresetMinutes(minutes: Int) { + dataStore.edit { it[PRESET_MINUTES] = minutes.coerceIn(1, 300) } + } + + override suspend fun updateLaunchAnimationEnabled(enabled: Boolean) { + dataStore.edit { it[LAUNCH_ANIMATION_ENABLED] = enabled } + } +} +``` + +- [ ] **Step 2: Build verifizieren** + +Run: `./gradlew :core:data:assembleDebug` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: Lint verifizieren** + +Run: `./gradlew :core:data:lintDebug` +Expected: BUILD SUCCESSFUL, keine neuen Warnings + +- [ ] **Step 4: Commit** + +```bash +git add core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/UserSettings.kt \ + core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepository.kt \ + core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepositoryImpl.kt +git commit -m "feat(core-data): add launchAnimationEnabled setting with reduce-motion seed" +``` + +--- + +## Task 5: `SettingsViewModel`-Handler anlegen + +**Files:** +- Modify: `feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsViewModel.kt` + +- [ ] **Step 1: Neue Methode am Ende der Klasse hinzufügen** + +Füge direkt vor der schließenden Klammer der Klasse (nach `updateStepMinutes`) hinzu: + +```kotlin + fun updateLaunchAnimationEnabled(enabled: Boolean) { + viewModelScope.launch { settingsRepository.updateLaunchAnimationEnabled(enabled) } + } +``` + +- [ ] **Step 2: Build verifizieren** + +Run: `./gradlew :feature:timer:assembleDebug` +Expected: BUILD SUCCESSFUL + +--- + +## Task 6: Strings für das neue Toggle (EN + DE) + +**Files:** +- Modify: `feature/timer/src/main/res/values/strings.xml` +- Modify: `feature/timer/src/main/res/values-de/strings.xml` + +- [ ] **Step 1: Englische Strings hinzufügen** + +Füge in `feature/timer/src/main/res/values/strings.xml` direkt nach der Zeile `Drifting starfield behind the dial` hinzu: + +```xml + Launch animation + Rocket-style play button animation when starting the timer +``` + +- [ ] **Step 2: Deutsche Strings hinzufügen** + +Füge in `feature/timer/src/main/res/values-de/strings.xml` direkt nach der Zeile `Driftendes Sternenfeld hinter dem Dial` hinzu: + +```xml + Launch-Animation + Raketenartige Animation des Play-Buttons beim Timer-Start +``` + +- [ ] **Step 3: Build verifizieren** + +Run: `./gradlew :feature:timer:assembleDebug` +Expected: BUILD SUCCESSFUL + +--- + +## Task 7: Toggle-Row in `SettingsScreen` einhängen + +**Files:** +- Modify: `feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsScreen.kt` + +- [ ] **Step 1: Import hinzufügen** + +Stelle sicher dass `import androidx.compose.material.icons.filled.RocketLaunch` am Anfang der Datei vorhanden ist (im Import-Block neben den anderen `material.icons.filled.*`-Imports). + +- [ ] **Step 2: Toggle-Row einfügen** + +In `SettingsScreen.kt`, direkt nach dem bestehenden `SettingsToggleRow` für `starsEnabled` (Zeilen ~215-226) und vor dem `SectionHeader(stringResource(R.string.category_sleep_timer))` (Zeile ~228) einfügen: + +```kotlin + SettingsToggleRow( + icon = Icons.Default.RocketLaunch, + title = stringResource(R.string.launch_animation_title), + description = stringResource(R.string.launch_animation_description), + checked = uiState.settings.launchAnimationEnabled, + onCheckedChange = { viewModel.updateLaunchAnimationEnabled(it) }, + ) +``` + +- [ ] **Step 3: Build verifizieren** + +Run: `./gradlew :feature:timer:assembleDebug` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 4: Manuelle Verifikation** + +Install Debug-APK: `./gradlew :app:installDebug` +Öffne App → Settings → neues Toggle „Launch-Animation" erscheint unter „Sterne". +Toggle an/aus, App kill & neu öffnen → Wert ist persistiert. +Bei frischer Installation mit aktivem System-Reduce-Motion: Toggle erscheint als *aus*. Ohne Reduce-Motion: Toggle erscheint als *an*. + +- [ ] **Step 5: Commit** + +```bash +git add feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsViewModel.kt \ + feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsScreen.kt \ + feature/timer/src/main/res/values/strings.xml \ + feature/timer/src/main/res/values-de/strings.xml +git commit -m "feat(settings): add launch animation toggle" +``` + +--- + +## Task 8: `PlayButton` um `crouchProgress` + `iconLaunching` erweitern + +**Files:** +- Modify: `feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/PlayButton.kt` + +**Kontext:** Der `PlayButton` bleibt funktional identisch, wenn die neuen Parameter ihre Default-Werte haben (`crouchProgress = 0f`, `iconLaunching = false`). Bei `crouchProgress > 0`: Button schrumpft leicht, Icon rotiert auf `targetIconRotationDeg` und verkleinert sich. Bei `iconLaunching = true`: das Play-Icon ist komplett transparent (weil das Overlay es gerade rendert). + +- [ ] **Step 1: Signatur erweitern und Body anpassen** + +Ersetze die gesamte Composable `PlayButton` (Zeilen 37-115) durch: + +```kotlin +@Composable +fun PlayButton( + isRunning: Boolean, + hapticEnabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + iconRotation: Float = 0f, + crouchProgress: Float = 0f, + iconLaunching: Boolean = false, + targetIconRotationDeg: Float = 0f, +) { + val theme = appTheme() + val view = LocalView.current + + val morph by animateFloatAsState( + targetValue = if (isRunning) 1f else 0f, + animationSpec = tween(durationMillis = 240, easing = FastOutSlowInEasing), + label = "playMorph", + ) + // 0 = circle (50% corners), 1 = rounded square (~28% corners) + val cornerPercent = (50f - 22f * morph).toInt().coerceIn(28, 50) + val shape = RoundedCornerShape(percent = cornerPercent) + + val shadowModifier = if (theme.hasGradient) { + Modifier.shadow( + elevation = 20.dp, + shape = shape, + ambientColor = theme.accent, + spotColor = theme.accent, + ) + } else { + Modifier + } + + // Linear interpolate button scale: 1.0 at crouchProgress=0, 0.92 at crouchProgress=1 + val buttonScale = 1f - 0.08f * crouchProgress.coerceIn(0f, 1f) + // Icon rotation during crouch: 0° → targetIconRotationDeg as crouchProgress goes 0→1. + // `iconRotation` (orientation-based) is added on top so landscape still rotates the + // idle icon correctly. + val crouchRotation = targetIconRotationDeg * crouchProgress.coerceIn(0f, 1f) + // Icon scale during crouch: 1.0 → 0.9 + val crouchIconScale = 1f - 0.1f * crouchProgress.coerceIn(0f, 1f) + + Box( + modifier = modifier + .size(84.dp) + .graphicsLayer { + scaleX = buttonScale + scaleY = buttonScale + } + .then(shadowModifier) + .clip(shape) + .clickable { + if (hapticEnabled) { + view.performHapticFeedback(playStopHaptic) + } + onClick() + }, + contentAlignment = Alignment.Center, + ) { + Canvas(modifier = Modifier.size(84.dp)) { + val cornerRadiusPx = (cornerPercent / 100f) * size.minDimension + val corner = CornerRadius(cornerRadiusPx, cornerRadiusPx) + drawRoundRect( + color = theme.accent, + cornerRadius = corner, + ) + drawRoundRect( + brush = Brush.verticalGradient( + 0f to Color.White.copy(alpha = if (theme.isDark) 0.45f else 0.30f), + 1f to Color.White.copy(alpha = 0f), + startY = 0f, + endY = size.height * 0.55f, + ), + cornerRadius = corner, + ) + } + Crossfade( + targetState = isRunning, + animationSpec = tween(durationMillis = 180), + label = "playIcon", + ) { running -> + val icon: ImageVector = if (running) Icons.Default.Stop else Icons.Default.PlayArrow + val desc = stringResource(if (running) R.string.stop_timer else R.string.start_timer) + // Play icon hides while the launch overlay is flying. + val iconAlpha = if (!running && iconLaunching) 0f else 1f + Icon( + imageVector = icon, + contentDescription = desc, + tint = theme.accentInk, + modifier = Modifier + .size(34.dp) + .graphicsLayer { + rotationZ = iconRotation + (if (!running) crouchRotation else 0f) + scaleX = if (!running) crouchIconScale else 1f + scaleY = if (!running) crouchIconScale else 1f + alpha = iconAlpha + }, + ) + } + } +} +``` + +- [ ] **Step 2: Build verifizieren** + +Run: `./gradlew :feature:timer:assembleDebug` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: Manuelle Verifikation: Button unverändert ohne Parameter** + +Install Debug-APK. App öffnen. Play-Button tippen → Crossfade zu Stop (bisheriges Verhalten). Zurück zu Idle per Stop. Keine visuelle Regression gegenüber vor der Änderung. + +- [ ] **Step 4: Commit** + +```bash +git add feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/PlayButton.kt +git commit -m "feat(timer-ui): extend PlayButton with crouch + launching parameters" +``` + +--- + +## Task 9: `CircularDial` um `impactPulse` erweitern + +**Files:** +- Modify: `feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/CircularDial.kt` + +**Kontext:** Ein einziger `impactPulse: Float` Parameter (0..1) steuert alle drei Impact-Effekte (Shockwave, Knob-Aura, Ring-Boost). Der Controller wird später Werte zwischen 0 und 1 für die ~260ms-Impact-Phase einspeisen. Bei `impactPulse == 0f` ist das Dial-Rendering bit-identisch zum bisherigen Zustand. + +- [ ] **Step 1: Parameter zur Signatur hinzufügen** + +Ersetze die Signatur (Zeilen 37-46): + +```kotlin +@Composable +fun CircularDial( + state: CircularDialState, + isRunning: Boolean, + runningMinutes: Float, + hapticEnabled: Boolean, + onMinutesChanged: (Int) -> Unit, + onMinutesCommitted: (Int) -> Unit, + modifier: Modifier = Modifier, + impactPulse: Float = 0f, +) { +``` + +- [ ] **Step 2: Impact-Rendering nach `drawKnob` einbauen** + +Im `Canvas`-Block, direkt nach dem `drawKnob(...)`-Call (Zeile ~196-202), füge hinzu: + +```kotlin + if (impactPulse > 0f) { + drawImpactEffects( + center = center, + ringRadius = radius, + strokeWidth = strokeWidth, + knobFraction = ringFraction, + pulse = impactPulse.coerceIn(0f, 1f), + accent = theme.accent, + ) + } +``` + +- [ ] **Step 3: Neue Draw-Funktion am Dateiende einfügen** + +Füge am Ende der Datei (nach `drawKnob`) diese neue `private fun` hinzu: + +```kotlin +private fun DrawScope.drawImpactEffects( + center: Offset, + ringRadius: Float, + strokeWidth: Float, + knobFraction: Float, + pulse: Float, + accent: androidx.compose.ui.graphics.Color, +) { + // Knob-Position reproducing drawKnob's math. + val angle = knobFraction * (2f * Math.PI.toFloat()) - (Math.PI.toFloat() / 2f) + val kx = center.x + ringRadius * cos(angle) + val ky = center.y + ringRadius * sin(angle) + + // 1) Knob aura bloom — radius expands, alpha fades. + val auraRadius = 16.dp.toPx() + (60.dp.toPx() - 16.dp.toPx()) * pulse + val auraAlpha = (1f - pulse).coerceAtLeast(0f) * 0.55f + drawCircle( + color = accent.copy(alpha = auraAlpha), + radius = auraRadius, + center = Offset(kx, ky), + ) + + // 2) Three concentric shockwave ripples, phase-shifted. + // Each ripple has its own normalized lifetime within the impact pulse. + val rippleOffsets = floatArrayOf(0f, 0.12f, 0.24f) + for (offset in rippleOffsets) { + val local = ((pulse - offset) / (1f - offset)).coerceIn(0f, 1f) + if (local <= 0f) continue + val rippleRadius = 10.dp.toPx() + (70.dp.toPx() - 10.dp.toPx()) * local + val rippleStroke = (6.dp.toPx() - 5.5f.dp.toPx() * local).coerceAtLeast(0.5f.dp.toPx()) + val rippleAlpha = (1f - local) * 0.9f + drawCircle( + color = accent.copy(alpha = rippleAlpha), + radius = rippleRadius, + center = Offset(kx, ky), + style = Stroke(width = rippleStroke), + ) + } + + // 3) Ring-Alpha-Boost: bright flash layered over the existing progress arc. + // Eases in quickly (0→0.3) and fades over the rest of the pulse. + val boostAlpha = when { + pulse < 0.3f -> pulse / 0.3f + else -> (1f - pulse) / 0.7f + }.coerceIn(0f, 1f) * 0.4f + if (boostAlpha > 0f) { + drawCircle( + color = accent.copy(alpha = boostAlpha), + radius = ringRadius, + center = center, + style = Stroke(width = strokeWidth, cap = StrokeCap.Round), + ) + } +} +``` + +- [ ] **Step 4: Build verifizieren** + +Run: `./gradlew :feature:timer:assembleDebug` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 5: Manuelle Verifikation** + +App öffnen: Dial sieht exakt wie vorher aus (impactPulse default 0). +Zur Verifikation des neuen Codes: temporär in `TimerScreen.kt` das Argument `impactPulse = 0.5f` an `CircularDial(...)` übergeben. Neu bauen → Dial zeigt ein „eingefrorenes" Impact-Bild (mittlere Shockwave, Knob-Aura). **Änderung danach zurücknehmen.** + +- [ ] **Step 6: Commit** + +```bash +git add feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/CircularDial.kt +git commit -m "feat(timer-ui): add impact pulse rendering to CircularDial" +``` + +--- + +## Task 10: `LaunchAnimation.kt` — Phase + Controller-Skelett + +**Files:** +- Create: `feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/LaunchAnimation.kt` + +- [ ] **Step 1: Datei mit Phase-Enum + Controller anlegen** + +```kotlin +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.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +enum class LaunchPhase { Idle, Crouch, Launch, Impact } + +/** + * Orchestriert die Rocket-Launch-Animation in einer Coroutine. Hält alle Animatable-Werte + * als Public-Properties, damit `TimerScreen` und `LaunchOverlay` sie lesen können. + * + * Der Controller weiß nichts vom Service oder Timer-State — er spielt nur die visuelle + * Choreographie ab. + */ +class LaunchAnimationController(private val scope: CoroutineScope) { + var phase by mutableStateOf(LaunchPhase.Idle) + private set + + // 1.0 im Idle, 0.92 auf dem Höhepunkt des Crouch, 1.04 beim Impact-Recoil. + val buttonScale = Animatable(1f) + // Absoluter Winkel des Play-Icons in Grad. + val iconRotationDeg = Animatable(0f) + // Fortschritt der Icon-Reise: 0 = Button-Center, 1 = Dial-Center. + val iconTravel = Animatable(0f) + // Icon-Scale während des Fluges (1.1 → 0.2 für Perspektivillusion). + val iconScale = Animatable(1f) + // Crouch-Intensität 0..1, steuert Button-Schrumpfung im PlayButton. + val crouchProgress = Animatable(0f) + // Impact-Pulse 0..1, wird ans Dial weitergereicht. + val impactPulse = Animatable(0f) + + private var currentJob: Job? = null + + /** + * 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). + */ + fun launch(targetIconRotationDeg: Float) { + if (phase != LaunchPhase.Idle) return + 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) + // Parallel animieren + launch { buttonScale.animateTo(0.92f, crouchSpec) } + launch { iconRotationDeg.animateTo(targetIconRotationDeg, crouchSpec) } + launch { crouchProgress.animateTo(1f, crouchSpec) } + kotlinx.coroutines.delay(140) + + // Phase 2: Launch (140–560ms) + phase = LaunchPhase.Launch + val launchEasing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f) + val launchSpec = tween(420, easing = launchEasing) + launch { buttonScale.animateTo(1f, tween(180)) } + launch { iconTravel.animateTo(1f, launchSpec) } + // Icon scale: 1.0 → 1.1 → 0.2 across the flight (rough approximation using two segments). + launch { + iconScale.animateTo(1.1f, tween(120, easing = launchEasing)) + iconScale.animateTo(0.2f, tween(300, easing = launchEasing)) + } + kotlinx.coroutines.delay(420) + + // Phase 3: Impact (560–820ms) + phase = LaunchPhase.Impact + val impactEasing = CubicBezierEasing(0.2f, 0.7f, 0.3f, 1f) + val impactSpec = tween(260, easing = impactEasing) + launch { + buttonScale.animateTo(1.04f, tween(130, easing = CubicBezierEasing(0.2f, 1.8f, 0.4f, 1f))) + buttonScale.animateTo(1f, tween(130)) + } + launch { impactPulse.animateTo(1f, impactSpec) } + kotlinx.coroutines.delay(260) + + // Zurück auf Idle (snap, nicht animiert, weil nächstes Frame den echten Running-State hat). + reset() + } + } + + /** + * Bricht eine laufende Animation ab und snapt alle Werte auf Idle-Defaults zurück. + */ + fun reset() { + currentJob?.cancel() + currentJob = null + scope.launch { + buttonScale.snapTo(1f) + iconRotationDeg.snapTo(0f) + iconTravel.snapTo(0f) + iconScale.snapTo(1f) + crouchProgress.snapTo(0f) + impactPulse.snapTo(0f) + } + phase = LaunchPhase.Idle + } +} + +@Composable +fun rememberLaunchAnimationController(): LaunchAnimationController { + val scope = rememberCoroutineScope() + return remember(scope) { LaunchAnimationController(scope) } +} +``` + +- [ ] **Step 2: Build verifizieren** + +Run: `./gradlew :feature:timer:assembleDebug` +Expected: BUILD SUCCESSFUL + +--- + +## Task 11: `LaunchOverlay` Composable in derselben Datei + +**Files:** +- Modify: `feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/LaunchAnimation.kt` + +**Kontext:** Die Overlay-Composable liest Button- und Dial-Center (in Root-Koordinaten) und rendert während der `Launch`-Phase das fliegende Play-Icon. Platzierung: als Sibling zum Main-`Column` innerhalb der `TimerBackground`-Box — da die Background-Box vollflächig bei `(0,0)` im Root startet, mappen Root-Koordinaten 1:1 auf die Overlay-Child-Position. + +Während `Crouch` ist das Icon noch im Button (dort animiert), während `Impact` ist es „eingeschlagen" und komplett weg. Das Overlay rendert also ausschließlich in der `Launch`-Phase. + +- [ ] **Step 1: Imports am Dateianfang ergänzen** + +Im Import-Block von `LaunchAnimation.kt` hinzufügen (neben den bestehenden Animatable/Coroutine-Imports): + +```kotlin +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +``` + +- [ ] **Step 2: Overlay-Composable am Dateiende ergänzen** + +Füge am Ende von `LaunchAnimation.kt` hinzu: + +```kotlin +@Composable +fun LaunchOverlay( + controller: LaunchAnimationController, + buttonCenter: Offset, + dialCenter: Offset, + accentColor: Color, +) { + // Nur während der Flug-Phase rendern. In Crouch ist das Icon noch im Button, + // in Impact ist es „eingeschlagen" (und die Dial-Effekte übernehmen). + if (controller.phase != LaunchPhase.Launch) return + // Warten bis beide Positionen gemessen wurden — sonst würde das Icon in Frame 1 + // kurz bei (0,0) aufblitzen. + if (buttonCenter == Offset.Zero || dialCenter == Offset.Zero) return + + val travel = controller.iconTravel.value + val iconScaleValue = controller.iconScale.value + // Fade-out gegen Ende des Fluges, damit der Impact „nahtlos" übernimmt. + val alphaValue = + if (travel < 0.85f) 1f else (1f - (travel - 0.85f) / 0.15f).coerceIn(0f, 1f) + + val currentX = buttonCenter.x + (dialCenter.x - buttonCenter.x) * travel + val currentY = buttonCenter.y + (dialCenter.y - buttonCenter.y) * travel + + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = null, + tint = accentColor, + modifier = Modifier + .size(34.dp) + .graphicsLayer { + val halfPx = 17.dp.toPx() // Icon ist 34dp; Center-Offset ist die Hälfte. + translationX = currentX - halfPx + translationY = currentY - halfPx + rotationZ = controller.iconRotationDeg.value + scaleX = iconScaleValue + scaleY = iconScaleValue + alpha = alphaValue + }, + ) +} +``` + +**Hinweis zur Positionierung:** Das `Icon` wird vom Parent (die `TimerBackground`-Box in `TimerScreen`) bei `(0, 0)` platziert. `graphicsLayer { translationX, translationY }` verschiebt es dann nach der Layoutphase um die gewünschten Pixel. Innerhalb des `graphicsLayer`-Blocks ist `this` ein `GraphicsLayerScope`, der von `Density` erbt — deshalb funktioniert `17.dp.toPx()` direkt. + +- [ ] **Step 3: Build verifizieren** + +Run: `./gradlew :feature:timer:assembleDebug` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 4: Commit** + +```bash +git add feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/LaunchAnimation.kt +git commit -m "feat(timer-ui): add LaunchAnimationController and overlay composable" +``` + +--- + +## Task 12: Alles in `TimerScreen` verdrahten + +**Files:** +- Modify: `feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerScreen.kt` + +**Kontext:** Das ist der Task wo es zum ersten Mal visuell etwas gibt. Wir verknüpfen: +1. Button-Position messen (`onGloballyPositioned`) +2. Dial-Position messen (`onGloballyPositioned`) +3. Controller in Composable erzeugen +4. Trigger-Logik im `onToggle`: bei Idle + Animation-enabled → `controller.launch(angle)` +5. `LaunchOverlay` im Root-Box rendern +6. `impactPulse` an `CircularDial` durchreichen +7. Neue PlayButton-Parameter übergeben +8. Reduce-Motion-Check zur Laufzeit +9. Lifecycle-Reset bei Background + +- [ ] **Step 1: Imports ergänzen** + +Im Import-Block von `TimerScreen.kt` hinzufügen (alphabetisch einsortieren): + +```kotlin +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import dev.xitee.sleeptimer.core.data.util.isSystemReduceMotionEnabled +import dev.xitee.sleeptimer.feature.timer.timer.components.LaunchOverlay +import dev.xitee.sleeptimer.feature.timer.timer.components.LaunchPhase +import dev.xitee.sleeptimer.feature.timer.timer.components.rememberLaunchAnimationController +import kotlin.math.atan2 +``` + +- [ ] **Step 2: Controller-State + Positions-State in `TimerContent` hinzufügen** + +Direkt nach den vorhandenen `val orientation by rememberDeviceOrientation()`-Zeilen (Zeile ~104): + +```kotlin + val context = LocalContext.current + 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) { + launchController.reset() + } +``` + +(`context` existiert bereits weiter oben — prüfen und nicht doppelt deklarieren.) + +- [ ] **Step 3: Dial-Position messen** + +Im `Box` direkt um den `CircularDial` (aktuell Zeile ~289): + +```kotlin + Box( + modifier = Modifier + .size(dialSize) + .graphicsLayer { rotationZ = animatedAngle } + .onGloballyPositioned { coords -> + dialCenter = coords.boundsInRoot().center + }, + contentAlignment = Alignment.Center, + ) { + CircularDial( + state = dialState, + isRunning = isRunning, + runningMinutes = runningMinutes, + hapticEnabled = settings.hapticFeedbackEnabled, + onMinutesChanged = viewModel::setMinutes, + onMinutesCommitted = viewModel::commitMinutes, + impactPulse = launchController.impactPulse.value, + modifier = Modifier.fillMaxSize(), + ) + ... +``` + +- [ ] **Step 4: Button-Position messen + neue Parameter übergeben** + +In der `ActionRow`-Composable (Zeile ~482), und der `PlayButton`-Instanziierung darin (Zeile ~509): die `ActionRow`-Signatur muss erweitert werden, damit sie die Launch-Animation-Parameter durchreichen kann. Ersetze die komplette `ActionRow`-Composable (Zeilen 482-524) durch: + +```kotlin +@Composable +private fun ActionRow( + isRunning: Boolean, + hapticEnabled: Boolean, + iconRotation: Float, + onToggle: () -> Unit, + onMinusStep: () -> Unit, + onPlusStep: () -> Unit, + isMinusEnabled: Boolean, + isPlusEnabled: Boolean, + plusStepVisibleWhileRunning: Boolean, + crouchProgress: Float, + iconLaunching: Boolean, + targetIconRotationDeg: Float, + buttonScale: Float, + onButtonPositioned: (Offset) -> Unit, +) { + androidx.compose.foundation.layout.Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + SecondaryRoundButton( + icon = Icons.Default.Remove, + contentDescription = stringResource(R.string.cd_step_minus), + onClick = onMinusStep, + hapticEnabled = hapticEnabled, + enabled = isMinusEnabled, + iconRotation = iconRotation, + ) + PlayButton( + isRunning = isRunning, + hapticEnabled = hapticEnabled, + onClick = onToggle, + iconRotation = iconRotation, + crouchProgress = crouchProgress, + iconLaunching = iconLaunching, + targetIconRotationDeg = targetIconRotationDeg, + buttonScale = buttonScale, + modifier = Modifier.onGloballyPositioned { coords -> + onButtonPositioned(coords.boundsInRoot().center) + }, + ) + SecondaryRoundButton( + icon = Icons.Default.Add, + contentDescription = stringResource(R.string.cd_step_plus), + onClick = onPlusStep, + hapticEnabled = hapticEnabled, + enabled = if (isRunning) plusStepVisibleWhileRunning else isPlusEnabled, + iconRotation = iconRotation, + ) + } +} +``` + +- [ ] **Step 5: `ActionRow`-Aufruf in `TimerContent` anpassen** + +In `TimerContent`, beim `ActionRow`-Call (aktuell Zeile ~336–381): neue Parameter übergeben und `onToggle` ausbauen für Animation-Trigger. Der Block `val runningRemainingSeconds: Int = when (val s = uiState) { ... }` bleibt direkt **vor** der neuen `ActionRow(...)`-Call stehen (unverändert) — die neuen `val launchPhase`/`val targetIconAngleDeg`-Deklarationen dann zwischen `runningRemainingSeconds` und `ActionRow(...)` einfügen. + +Alte `ActionRow(...)`-Zeilen ~336–381 ersetzen durch: + +```kotlin + val launchPhase = launchController.phase + val iconLaunching = launchPhase == LaunchPhase.Launch || + launchPhase == LaunchPhase.Impact + // 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, + hapticEnabled = settings.hapticFeedbackEnabled, + iconRotation = animatedAngle, + onToggle = { + val animating = launchPhase != LaunchPhase.Idle + if (isRunning || animating) { + viewModel.stopTimer() + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) != PackageManager.PERMISSION_GRANTED + ) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + viewModel.startTimer() + if (animationEnabled && + buttonCenter != Offset.Zero && + dialCenter != Offset.Zero + ) { + launchController.launch(targetIconAngleDeg) + } + } + }, + onMinusStep = { + if (isRunning) { + viewModel.subtractStep() + } else { + val step = settings.stepMinutes + val current = dialState.totalMinutes + val next = ((current - 1).coerceAtLeast(0) / step) * step + viewModel.commitMinutes(next) + } + }, + onPlusStep = { + if (isRunning) { + viewModel.addStep() + } else { + val step = settings.stepMinutes + val current = dialState.totalMinutes + val next = (current / step + 1) * step + viewModel.commitMinutes(next.coerceAtMost(300)) + } + }, + isMinusEnabled = if (isRunning) { + runningRemainingSeconds > settings.stepMinutes * 60 + } else { + dialState.totalMinutes > 1 + }, + isPlusEnabled = !isRunning && dialState.totalMinutes < 300, + plusStepVisibleWhileRunning = true, + crouchProgress = launchController.crouchProgress.value, + iconLaunching = iconLaunching, + targetIconRotationDeg = targetIconAngleDeg, + buttonScale = launchController.buttonScale.value, + onButtonPositioned = { buttonCenter = it }, + ) +``` + +- [ ] **Step 6: `LaunchOverlay` am Ende der Root-`TimerBackground`-Box einbauen** + +In `TimerContent`, innerhalb des `TimerBackground { ... }`-Blocks, *nach* dem Landscape-Title-`AnimatedVisibility` (das heute der letzte Block ist, Zeilen ~392-417), ergänze: + +```kotlin + LaunchOverlay( + controller = launchController, + buttonCenter = buttonCenter, + dialCenter = dialCenter, + accentColor = appTheme().accent, + ) +``` + +- [ ] **Step 7: Build verifizieren** + +Run: `./gradlew :feature:timer:assembleDebug` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 8: Lint verifizieren** + +Run: `./gradlew :feature:timer:lintDebug` +Expected: BUILD SUCCESSFUL, keine neuen Warnings + +- [ ] **Step 9: Manuelle Verifikation (Kernfunktion)** + +Install Debug-APK. App öffnen: +- Settings-Toggle „Launch-Animation" aktivieren (falls System Reduce-Motion aus ist, ist es schon an) +- Zurück zum Timer-Screen, 15 min eingestellt +- Play-Button tippen → Button drückt kurz rein (Crouch), Play-Icon rotiert nach oben und fliegt zum Dial-Zentrum, am Knob (oben) entsteht ein Ring-Pulse + Shockwave, Button zeigt danach Stop-Icon +- Stop drücken → Timer stoppt + +- [ ] **Step 10: Commit** + +```bash +git add feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerScreen.kt +git commit -m "feat(timer-ui): wire launch animation into TimerScreen" +``` + +--- + +## Task 13: Regressions- und Orientation-Tests + +**Files:** keine Änderungen, rein manuelle Verifikation. Falls Bugs auftauchen, fix als separater Commit. + +- [ ] **Step 1: Build-Sanity** + +``` +./gradlew assembleDebug +./gradlew lint +./gradlew assembleRelease +``` +Alle: BUILD SUCCESSFUL. + +- [ ] **Step 2: Test-Matrix abarbeiten** + +Debug-APK installieren. Folgende Fälle durchgehen und bei Abweichungen notieren: + +| # | Szenario | Erwartet | +|---|----------|----------| +| 1 | Portrait, 15 min, Tap Play | Rocket fliegt nach oben, Impact am Knob (12-Uhr-Position) | +| 2 | Portrait, 45 min, Tap Play | Rocket fliegt nach oben, Impact am Knob (9-Uhr-Position, links) | +| 3 | Portrait, 90 min, Tap Play | Ring overflow-gefüllt, Knob bei 12-Uhr (30 min modulo 60), Impact dort | +| 4 | Landscape (Gerät rotieren), 15 min, Tap Play | Rocket fliegt auf der physischen Achse Button→Dial (also „nach oben" auf dem Bildschirm, aus User-Perspektive seitlich), Impact am Knob, den das rotierte Dial zeigt | +| 5 | Portrait, Tap Play, dann 300ms später Tap nochmal | Timer stoppt sofort, Animation läuft sichtbar bis Ende, Button zeigt danach Play | +| 6 | Settings-Toggle off → Tap Play | Kein Rocket-Flug, direkter Crossfade Play→Stop | +| 7 | Settings-Toggle on, System-Reduce-Motion on → Tap Play | Kein Rocket-Flug (Runtime-Gate greift) | +| 8 | Frischinstall mit System-Reduce-Motion on | Settings-Toggle erscheint als *aus* (seed-Default) | +| 9 | Frischinstall ohne Reduce-Motion | Settings-Toggle erscheint als *an* | +| 10 | Play drücken, App in Background (Home-Button) während Animation, wieder öffnen | Controller resettet, Button spiegelt echten Timer-State (Running falls Service gestartet hat) | +| 11 | Play drücken + sofort Gerät drehen | Composable neu, neue Positions-Messung, keine Crashes; Animation beim nächsten Tap wieder ok | +| 12 | Erster App-Start mit Android 13+ ohne Notification-Permission | Permission-Dialog erscheint *vor* der Animation; keine Rocket beim ersten Tap | +| 13 | Settings-Screen: deutsche Sprache | Toggle-Label „Launch-Animation" mit Beschreibung „Raketenartige Animation..." | +| 14 | Settings-Screen: englische Sprache | Toggle-Label „Launch animation" mit Beschreibung „Rocket-style..." | + +- [ ] **Step 3: Bei Fund: Fix als separater Commit** + +Für jeden Bug: minimaler Fix, manuelle Re-Verifikation, commit mit Message `fix(timer-ui): `. + +--- + +## Task 14: Final-Commit falls gewünscht + +**Kontext:** Der Spec und dieses Plan-Dokument sind bisher nicht committet. Falls gewünscht, jetzt mitnehmen. + +- [ ] **Step 1: Status checken** + +Run: `git status` +Expected: eventuell `docs/superpowers/specs/2026-04-20-launch-animation-design.md` + `docs/superpowers/plans/2026-04-20-launch-animation.md` als untracked. + +- [ ] **Step 2: Design-Doku einchecken** + +```bash +git add docs/superpowers/specs/2026-04-20-launch-animation-design.md \ + docs/superpowers/plans/2026-04-20-launch-animation.md +git commit -m "docs: add launch animation design spec and implementation plan" +``` + +--- + +## Open follow-ups + +- Wenn `impactPulse` als einzelner Float sich beim Feintuning als zu undifferenziert herausstellt: auf drei Parameter (`shockwaveProgress`, `knobPulseProgress`, `ringBoost`) splitten. Bis dahin YAGNI. +- Wenn die Rocket in Landscape aus User-Perspektive zu kurios aussieht: in einem Follow-up-Spec die Option B/C aus der Brainstorming-Session (Rotated-Frame-Animation) neu bewerten. diff --git a/docs/superpowers/specs/2026-04-20-launch-animation-design.md b/docs/superpowers/specs/2026-04-20-launch-animation-design.md new file mode 100644 index 0000000..666ecb6 --- /dev/null +++ b/docs/superpowers/specs/2026-04-20-launch-animation-design.md @@ -0,0 +1,211 @@ +# Play-Button Launch-Animation + +**Status:** design approved, ready for implementation planning +**Date:** 2026-04-20 + +## Context + +Der Play-Button ist aktuell ein statisches Play-Icon, das per Crossfade zum Stop-Icon wechselt, sobald der Timer läuft. Ein in Claude Design erstellter Prototyp (`docs/plans/` nicht committed; Referenz-HTML/JSX im Handoff-Bundle) zeigt eine Launch-Animation, bei der das Play-Icon beim Tap als „Rakete" Richtung Dial-Zentrum fliegt und dort auf das Knob-Element einschlägt — visuelles Storytelling für „Timer zündet". + +Ziel dieses Specs: Die Prototyp-Animation in Jetpack Compose umsetzen, ohne die Funktion des Dials im Idle-Zustand (Knob ziehbar, Progress-Arc sichtbar) zu verändern. + +## Scope + +**In-Scope:** +- Eine Animationsvariante (Rocket — gerader Schuss), umgesetzt für Portrait **und** Landscape +- User-Toggle in Settings zum Deaktivieren +- Default-Wert des Toggles berücksichtigt Android System-Reduce-Motion einmalig beim ersten Start +- System-Reduce-Motion wird zur Laufzeit respektiert (überspringt Animation immer) +- Impact-Effekt auf Dial: Shockwave vom Knob, Knob-Pulse, Ring-Boost (wie Prototyp) + +**Out-of-Scope:** +- Die Varianten „Arc toss" und „Warp" aus dem Prototyp +- Zusätzliche Haptics (Impact-Vibration) — der bestehende Tap-Haptic im PlayButton bleibt +- Unit-Tests (Projekt hat aktuell keine Tests; Animation ist reine UI-Choreographie) +- Tracking, ob User den Toggle je explizit gesetzt hat (System-Setting-Änderungen post-Install werden nicht auto-reflektiert) + +## User Experience + +### Animations-Phasen und Timing + +Nach Tap auf den Play-Button durchläuft die Animation drei Phasen (1:1 Timings aus dem Prototyp). Nach der letzten Phase fällt der Controller zurück auf `Idle`; der danach sichtbare „Running"-Zustand wird vollständig über `uiState` und den bestehenden `PlayButton`-Crossfade gerendert — der Controller hält keinen eigenen Running-State. + +| Phase | Zeitspanne | Button | Icon | Dial | +| -------- | ---------- | ----------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------- | +| Crouch | 0–140 ms | scale 1.0 → 0.92 | rotiert von 0° Richtung Dial, scale 1.0 → 0.9, bleibt im Button | unverändert | +| Launch | 140–560 ms | scale 0.92 → 1.0 | wird Overlay, fliegt vom Button-Zentrum zum Dial-Zentrum, Trail hinter| unverändert | +| Impact | 560–820 ms | scale 1.0 → 1.04 (Recoil-Puls, danach zurück) | unsichtbar (in Dial „aufgegangen") | Knob pulsiert, Ring-Glow verstärkt, 3× Shockwave-Ripple vom Knob | +| (Idle) | 820 ms+ | Controller auf Idle zurückgesetzt; Button-Rendering läuft über `uiState` (Stop-Icon-Crossfade bei `isRunning == true`) | + +### Icon-Rotation (orientation-agnostisch) + +Das Play-Icon zeigt im Idle-State nach rechts (Standard-Material-Play-Arrow). Die Ziel-Rotation beim Crouch wird dynamisch berechnet als Winkel zwischen dem Vektor (Button-Zentrum → Dial-Zentrum) und der horizontalen Achse. In Portrait sind Button und Dial vertikal angeordnet, das Icon rotiert auf -90° (zeigt nach oben). In Landscape ist die Anordnung identisch im Physical-Space (die `Column` in `TimerScreen` ist nicht rotiert — nur die Dial-Inhalte selbst via `graphicsLayer`), der Winkel bleibt -90°. + +Der Ansatz ist bewusst allgemein gehalten: sollte das Layout sich ändern (z.B. Button seitlich des Dials in einer zukünftigen Variante), bleibt die Animation korrekt, ohne Anpassung. + +### Flug-Trajektorie + +Linear: `buttonCenter + (dialCenter - buttonCenter) * t`, mit `t` per `cubic-bezier(.4,0,.2,1)` Easing-Kurve von 0 auf 1 über 420 ms. Icon schrumpft parallel von scale 1.1 → 0.2 (Perspektivillusion) und wird bei t≈0.85 unsichtbar (Impact-Einschlag). + +### Impact-Zielpunkt + +Rocket fliegt zum **Dial-Zentrum**. Der Impact-Effekt (Shockwave, Pulse) wird am **Knob** ausgelöst — narrative Idee: „Rocket schlägt im Dial-Zentrum ein, Energie reist zum Knob und entzündet den Timer". Knob-Position abhängig von aktueller `fraction = (selectedMinutes % 60) / 60`. + +### Cancel-Verhalten + +Der Button bleibt die gesamte Animation über tappbar. Tap während der Animation ruft `onToggle` auf, welches `viewModel.stopTimer()` triggert (Service-Cancel), sofern die Animation aktiv ist oder `uiState == Running`. Die Animation **läuft visuell weiter bis zum Ende** — es gibt keine Abbruch-Logik auf visueller Ebene. Nach Animation-Ende zeigt der Button Play (weil Timer gestoppt wurde), Dial-State reflektiert Idle. + +### Reduce-Motion und Settings-Toggle + +Zwei unabhängige Gates, beide müssen `true` liefern, damit die Animation spielt: + +1. **System Reduce-Motion:** zur Laufzeit geprüft via `Settings.Global.getFloat(ANIMATOR_DURATION_SCALE)`. Android hat keine eigene API wie iOS' `UIAccessibility.isReduceMotionEnabled`; die „Remove animations"-Accessibility-Toggle in Android setzt intern die drei Animation-Scale-Settings (`ANIMATOR_DURATION_SCALE`, `TRANSITION_ANIMATION_SCALE`, `WINDOW_ANIMATION_SCALE`) auf `0f`. `ANIMATOR_DURATION_SCALE == 0f` deckt daher sowohl die Accessibility-Toggle als auch die Developer-Options-Einstellung ab. +2. **App-Setting `launchAnimationEnabled`:** in DataStore persistiert. + +Wenn beide true: Animation spielt. Sonst: normaler bisheriger Crossfade Play→Stop ohne Flug. + +**Default-Wert des App-Settings:** Beim ersten Laden (DataStore liefert kein Value zurück) wird der System-Reduce-Motion-Status einmalig gelesen und als `!systemReduceMotion` persistiert. User-Overrides gewinnen danach. + +## Architecture + +### Neue Dateien + +- `feature/timer/src/main/kotlin/.../timer/components/LaunchAnimation.kt` + - Enthält: `LaunchPhase` Enum (`Idle`, `Crouch`, `Launch`, `Impact`), `LaunchAnimationController` (Animatable-basierte State-Machine), `rememberLaunchAnimationController()`, `LaunchOverlay` Composable + +**Warum Overlay statt Icon im Button zu animieren:** Das fliegende Icon muss über den Button hinaus bis zum Dial reisen. Als Child des Buttons würde es geclippt oder das Button-Layout müsste fix groß sein und den halben Screen reservieren. Ein Overlay in der Root-Box des `TimerScreen` hat den ganzen Screen als Canvas, ohne die anderen Komponenten zu beeinflussen. + +### Geänderte Dateien + +| Datei | Änderung | +|-------|----------| +| `core/data/.../model/UserSettings.kt` | Neues Feld `launchAnimationEnabled: Boolean = true` | +| `core/data/.../repository/SettingsRepositoryImpl.kt` | Neue `Preferences.Key`; `updateLaunchAnimationEnabled(Boolean)`; Default-Initialisierung liest einmalig `AccessibilityManager` beim ersten Mapping | +| `feature/timer/.../settings/SettingsScreen.kt` | Neuer `SettingsToggleRow` unter `starsEnabled`-Toggle | +| `feature/timer/.../settings/SettingsViewModel.kt` | `onLaunchAnimationEnabledChange(Boolean)`-Handler | +| `feature/timer/.../settings/SettingsUiState.kt` | Feld `launchAnimationEnabled: Boolean` | +| `feature/timer/.../timer/TimerScreen.kt` | Miss Button/Dial-Zentrum via `onGloballyPositioned`; Render `LaunchOverlay` im Root-Box; Trigger Phasen; leite `impactPulse` ans Dial; Reduce-Motion-Detection | +| `feature/timer/.../timer/components/CircularDial.kt` | Neuer Parameter `impactPulse: Float = 0f` (0..1); bei >0: Shockwave-Ripple vom Knob, Knob-Glow-Scale erhöht, Ring-Alpha-Boost | +| `feature/timer/.../timer/components/PlayButton.kt` | Neue Parameter `crouchProgress: Float = 0f`, `iconLaunching: Boolean = false`; bei crouchProgress>0: Button-Scale + Icon-Rotation/Scale; bei iconLaunching: Icon unsichtbar | +| `feature/timer/src/main/res/values/strings.xml` | Neuer String für Settings-Toggle | +| `feature/timer/src/main/res/values-de/strings.xml` | Deutsche Übersetzung | + +### Controller-API (Vorschlag) + +```kotlin +class LaunchAnimationController(private val scope: CoroutineScope) { + var phase by mutableStateOf(LaunchPhase.Idle) + private set + val buttonScale = Animatable(1f) + val iconRotationDeg = Animatable(0f) + val iconTravel = Animatable(0f) // 0 = Button, 1 = Dial + val iconScale = Animatable(1f) + val shockwave = Animatable(0f) // 0 = hidden, 1 = fully expanded + val knobPulse = Animatable(0f) + val ringBoost = Animatable(0f) + + /** + * Löst die Animation aus. Idempotent: wenn phase != Idle, no-op. + * Durchläuft Crouch → Launch → Impact sequenziell. Nach Impact: phase = Idle, + * alle Animatable-Werte auf Idle-Defaults (weiche Rückführung, nicht snap). + */ + fun launch(targetAngleDeg: Float) + + /** + * Bricht eine laufende Animation hart ab, snapt alle Werte auf Idle-Defaults. + * Wird aufgerufen bei App-Background oder Composable-Dispose. + */ + fun reset() +} +``` + +### Integration in TimerScreen + +```kotlin +val density = LocalDensity.current +var buttonCenter by remember { mutableStateOf(Offset.Zero) } +var dialCenter by remember { mutableStateOf(Offset.Zero) } +val controller = rememberLaunchAnimationController() +val animationsEnabled = settings.launchAnimationEnabled && !isSystemReduceMotion() + +// im Dial-Container: +Modifier.onGloballyPositioned { dialCenter = it.boundsInRoot().center } + +// im Button: +Modifier.onGloballyPositioned { buttonCenter = it.boundsInRoot().center } + +// onToggle: +if (isRunning || controller.phase != LaunchPhase.Idle) { + viewModel.stopTimer() +} else { + // Permission-Check wie bisher + viewModel.startTimer() + if (animationsEnabled) { + val angle = angleBetween(buttonCenter, dialCenter) + controller.launch(angle) + } +} + +// Overlay am Ende des Root-Box: +LaunchOverlay( + controller = controller, + buttonCenter = buttonCenter, + dialCenter = dialCenter, + themeAccent = appTheme().accent, +) +``` + +### Koordinaten-Modell + +Alle Positionen werden via `layoutCoordinates.boundsInRoot().center` in **Root-Koordinaten** ermittelt. Das Overlay rendert im selben Root-Space. Keine Transformationen über Orientation-Rotation nötig, weil die Container (Button-Row, Dial-Box) physisch am gleichen Platz bleiben — nur der Dial-Inhalt wird via `graphicsLayer` rotiert, was die Container-Bounds nicht beeinflusst. + +### Dial-Impact-Rendering + +Im `CircularDial.kt` Canvas, nach dem bisherigen `drawKnob`-Call: wenn `impactPulse > 0f`: + +- Knob-Aura-Radius animiert von 16dp auf ~60dp, alpha fade von 0.35 auf 0 +- 3× konzentrische Ring-Ripples am Knob, je 900ms Dauer mit 0/110/220ms Offset, stroke-Width shrinkt von 6→0.5, alpha 0.9→0 +- Ring-Hauptfarbe: `theme.accent` +- `ringFraction` Hauptarc bekommt +0.2 Alpha-Boost für ~400ms (macht den ganzen Ring kurz heller) + +Der `impactPulse`-Parameter selbst ist ein Float 0..1, der von `TimerScreen` aus `controller.shockwave.value` (oder ähnlich) durchgereicht wird. Alternative: einzelne Parameter `shockwaveProgress: Float`, `knobPulseProgress: Float`, `ringBoost: Float` für mehr Kontrolle. Entscheidung beim Implementieren: erst mit einem gemeinsamen Float starten, nur splitten wenn Fine-Tuning es erfordert. + +## Edge Cases + +| Fall | Verhalten | +|------|-----------| +| Animation läuft, App geht in Background | `DisposableEffect`/Lifecycle-Observer ruft `controller.reset()` — Button zeigt den echten State | +| Screen-Rotation während Animation | Composable wird neu erstellt, neuer Controller gestartet, alter cancelled. Animation verloren (akzeptabel) | +| Theme-Wechsel während Animation | `theme.accent` wird zur Draw-Zeit abgegriffen, Farbwechsel live | +| Service startet nicht (Exception) | Animation spielt komplett durch; Button fällt auf Play-Icon zurück, da `uiState` nie Running wird | +| User dreht Dial-Knob direkt nach Tap | Dial-Gestures bleiben aktiv; Knob-Position beim Impact nimmt die aktuelle `fraction` (live berechnet) | +| Notification-Permission-Dialog (Android 13+, erster Start) | Dialog zuerst, keine Animation bei diesem Tap. Erst beim nächsten Start fliegt die Rakete | +| User tappt Play, dann vor Animations-Ende nochmals | Zweiter Tap triggert `stopTimer()`. Animation läuft visuell weiter. Button-Icon am Ende: Play | + +## Testing + +Keine automatisierten Tests geplant (Projekt hat aktuell keine Tests; Animation ist reine UI). + +**Manuelle Verifikation (Debug-Build):** + +- Portrait: Tap Play bei 15min → Rocket fliegt nach oben, Impact + Shockwave vom Knob oben am Dial +- Portrait bei 45min: Knob links am Dial → Rocket fliegt trotzdem ins Zentrum, Impact-Effekt am linken Knob +- Portrait bei 90min (Overflow-Ring): Ring komplett gefüllt + Knob an neuer Position — Impact dort +- Landscape: Rocket fliegt zum Dial-Zentrum im Physical-Space (aus User-Perspektive „zur Seite"), Impact korrekt +- Tap während Launch-Phase → Timer stoppt sofort, Animation läuft durch, Button zeigt Play +- Settings-Toggle off → Tap Play → kein Flug, nur Crossfade +- Settings-Toggle on + System-Reduce-Motion on → kein Flug +- Settings-Toggle on + System-Reduce-Motion off → Flug +- Settings-Screen zeigt den neuen Toggle unter „Sterne" +- Deutsche Übersetzung vorhanden +- Build: `./gradlew assembleDebug` und `./gradlew lint` müssen grün bleiben +- Release-Build: `./gradlew assembleRelease` (mit Fallback auf Debug-Signing ohne Keystore) muss grün bleiben + +## Open Decisions + +- Genaue Wording/Sublabel des Settings-Toggles — festlegen beim Implementieren (siehe Strings-Änderungen; kurze klare Formulierung auf DE+EN) +- Ob `impactPulse` in `CircularDial` als einzelner Float oder drei Parameter splitten — mit einem starten, nur splitten wenn nötig + +## References + +Die Design-Entscheidungen und Timings stammen aus dem Claude-Design-Handoff-Bundle (temporär unter `/tmp/sleep-timer-design/sleep-timer-start-button-animation/` während der Design-Session verfügbar; nicht im Repo committed). Die für die Implementierung relevanten Details sind in diesem Spec vollständig festgehalten — das Bundle ist keine verlässliche Langzeit-Referenz. Falls benötigt, ist der React-Prototyp aus dem Bundle mit Standard-Tools (React + Babel Standalone über CDN) ohne Build-Schritt im Browser ausführbar.