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 } + } } 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 +} 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. 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/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..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 @@ -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,32 @@ private fun TimerContent( else -> 0 } + 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 + else { + val dx = dialCenter.x - buttonCenter.x + val dy = dialCenter.y - buttonCenter.y + Math.toDegrees(atan2(dy, dx).toDouble()).toFloat() + } + } + ActionRow( isRunning = isRunning, + playButtonShowsRunning = playButtonShowsRunning, 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 +394,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 +429,8 @@ private fun TimerContent( }, isPlusEnabled = !isRunning && dialState.totalMinutes < 300, plusStepVisibleWhileRunning = true, + buttonScale = launchController.buttonScale.value, + onButtonPositioned = { buttonCenter = it }, ) Spacer(modifier = Modifier.height(32.dp)) @@ -415,6 +468,15 @@ private fun TimerContent( ) } } + + LaunchOverlay( + controller = launchController, + isRunning = isRunning, + buttonCenter = buttonCenter, + dialCenter = dialCenter, + iconTint = appTheme().accentInk, + glowColor = appTheme().accent, + ) } } @@ -482,6 +544,7 @@ private fun HomeTopBar( @Composable private fun ActionRow( isRunning: Boolean, + playButtonShowsRunning: Boolean, hapticEnabled: Boolean, iconRotation: Float, onToggle: () -> Unit, @@ -490,6 +553,8 @@ private fun ActionRow( isMinusEnabled: Boolean, isPlusEnabled: Boolean, plusStepVisibleWhileRunning: Boolean, + buttonScale: Float, + onButtonPositioned: (Offset) -> Unit, ) { androidx.compose.foundation.layout.Row( modifier = Modifier @@ -507,10 +572,14 @@ private fun ActionRow( iconRotation = iconRotation, ) PlayButton( - isRunning = isRunning, + isRunning = playButtonShowsRunning, hapticEnabled = hapticEnabled, onClick = onToggle, iconRotation = iconRotation, + buttonScale = buttonScale, + modifier = Modifier.onGloballyPositioned { coords -> + onButtonPositioned(coords.boundsInRoot().center) + }, ) SecondaryRoundButton( icon = Icons.Default.Add, 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..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 @@ -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,16 @@ fun CircularDial( theme = theme, dimmed = isRunning && !state.isDragging, ) + + if (impactPulse > 0f) { + drawImpactEffects( + center = center, + ringRadius = radius, + strokeWidth = strokeWidth, + pulse = impactPulse.coerceIn(0f, 1f), + accent = theme.accent, + ) + } } } } @@ -377,3 +388,70 @@ private fun DrawScope.drawKnob( center = Offset(kx, ky), ) } + +private fun DrawScope.drawImpactEffects( + center: Offset, + ringRadius: Float, + strokeWidth: Float, + pulse: Float, + accent: Color, +) { + // 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) { + drawCircle( + brush = Brush.radialGradient( + 0f to Color.White.copy(alpha = bangAlpha), + 0.5f to Color.White.copy(alpha = bangAlpha * 0.45f), + 1f to Color.White.copy(alpha = 0f), + center = center, + radius = bangRadius, + ), + radius = bangRadius, + center = center, + ) + } + + // 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 = rippleStrokeStart + (rippleStrokeEnd - rippleStrokeStart) * local + val rippleAlpha = (1f - local) * 0.9f + drawCircle( + color = accent.copy(alpha = rippleAlpha), + radius = rippleRadius, + center = center, + style = Stroke(width = rippleStroke), + ) + } + + // 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 + }.coerceIn(0f, 1f) * 0.5f + if (boostAlpha > 0f) { + drawCircle( + color = accent.copy(alpha = boostAlpha), + radius = ringRadius, + center = center, + style = Stroke(width = strokeWidth, cap = StrokeCap.Round), + ) + } +} + +// 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 new file mode 100644 index 0000000..0ec32ac --- /dev/null +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/LaunchAnimation.kt @@ -0,0 +1,242 @@ +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.fillMaxSize +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.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.StrokeCap +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: 1.0 idle, 0.9 crouch (komprimiert), 1.1 → 0.2 während des Fluges. + val iconScale = Animatable(1f) + // 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 { iconScale.animateTo(0.9f, crouchSpec) } + delay(140) + + // Phase 2: Launch (140–560ms, 420ms) — drei Segmente mit ease-in-out pro + // Segment. Waypoints: Travel 0 → 0.28 (30%) → 0.84 (80%) → 1.0 (100%), + // Scale 0.9 → 1.1 → 0.9 → 0.5. An den Segmentgrenzen fällt die Velocity + // nahezu auf Null (ease-out des einen, ease-in des nächsten) — das + // erzeugt die charakteristischen „Hang"-Momente. + phase = LaunchPhase.Launch + val launchEasing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f) + launch { buttonScale.animateTo(1f, tween(180)) } + launch { + iconTravel.animateTo(0.28f, tween(126, easing = launchEasing)) + iconTravel.animateTo(0.84f, tween(210, easing = launchEasing)) + iconTravel.animateTo(1f, tween(84, easing = launchEasing)) + } + launch { + iconScale.animateTo(1.1f, tween(126, easing = launchEasing)) + iconScale.animateTo(0.9f, tween(210, easing = launchEasing)) + iconScale.animateTo(0.5f, tween(84, easing = launchEasing)) + } + delay(420) + + // 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) + launch { + buttonScale.animateTo( + 1.04f, + tween(130, easing = CubicBezierEasing(0.2f, 1.8f, 0.4f, 1f)), + ) + buttonScale.animateTo(1f, tween(170)) + } + launch { impactPulse.animateTo(1f, impactSpec) } + delay(600) + + // 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) + impactPulse.snapTo(0f) + } + phase = LaunchPhase.Idle + } +} + +@Composable +fun rememberLaunchAnimationController(): LaunchAnimationController { + val scope = rememberCoroutineScope() + 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, + iconTint: Color, + glowColor: Color, +) { + val phase = controller.phase + val shouldRender = when (phase) { + LaunchPhase.Impact -> false + LaunchPhase.Idle -> !isRunning + LaunchPhase.Crouch, LaunchPhase.Launch -> true + } + if (!shouldRender) return + // Brauchen mindestens Button-Position; Dial-Position ist nur relevant sobald + // travel > 0 (wird in onToggle vor controller.launch() gegated). + if (buttonCenter == Offset.Zero) return + + val travel = controller.iconTravel.value + val iconScaleValue = controller.iconScale.value + // Icon fadet über die letzten 20% des Fluges aus — matched den Prototyp. + val alphaValue = + if (travel < 0.8f) 1f else (1f - (travel - 0.8f) / 0.2f).coerceIn(0f, 1f) + // Glow um das Icon herum — wächst mit Entfernung vom Button. + val glowAlpha = travel.coerceIn(0f, 1f) * 0.9f + + val currentX = buttonCenter.x + (dialCenter.x - buttonCenter.x) * travel + val currentY = buttonCenter.y + (dialCenter.y - buttonCenter.y) * travel + + // 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) + .graphicsLayer { + val halfPx = 30.dp.toPx() + translationX = currentX - halfPx + translationY = currentY - halfPx + rotationZ = controller.iconRotationDeg.value + scaleX = iconScaleValue + 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 dbfb756..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,6 +49,7 @@ fun PlayButton( onClick: () -> Unit, modifier: Modifier = Modifier, iconRotation: Float = 0f, + buttonScale: Float = 1f, ) { val theme = appTheme() val view = LocalView.current @@ -65,9 +74,17 @@ fun PlayButton( Modifier } + val description = stringResource( + if (isRunning) R.string.stop_timer else R.string.start_timer, + ) + Box( modifier = modifier .size(84.dp) + .graphicsLayer { + scaleX = buttonScale + scaleY = buttonScale + } .then(shadowModifier) .clip(shape) .clickable { @@ -75,7 +92,8 @@ fun PlayButton( view.performHapticFeedback(playStopHaptic) } onClick() - }, + } + .semantics { contentDescription = description }, contentAlignment = Alignment.Center, ) { Canvas(modifier = Modifier.size(84.dp)) { @@ -98,18 +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) - Icon( - imageVector = icon, - contentDescription = desc, - tint = theme.accentInk, - modifier = Modifier - .size(34.dp) - .graphicsLayer { rotationZ = iconRotation }, - ) + if (running) { + Icon( + imageVector = Icons.Default.Stop, + contentDescription = null, + tint = theme.accentInk, + modifier = Modifier + .size(34.dp) + .graphicsLayer { rotationZ = iconRotation }, + ) + } } } } 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