diff --git a/README.md b/README.md index fe848f6..f4a10fb 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,8 @@ A clock and alarm app with the look and flow of the Pixel clock: alarms in expan - **Ringing done right:** a full-screen screen over the lock screen with slide to snooze or stop, alarm-stream audio that gets louder gradually, vibration, automatic silence after a set time, and notifications for upcoming, snoozed and missed alarms. Volume keys can snooze or stop. - **No network permission at all.** World clock cities come from the system time zone database (names localised offline), sounds from the device, the app's own tone, or your own audio files. - **Also:** days and labels per alarm, pause until a date, skip the next ring, timers with ring progress and +1:00 that keep running in the background with a live countdown notification, stopwatch with fastest/slowest laps, analog or digital clock, wallpaper colours (Material You), three home-screen widgets (live digital clock, analog clock, next alarm). -- **Coming next:** widget style options like Wetter, screensaver/night clock, sunrise alarm, sleep sounds, reordering world clocks. +- **Bedtime extras:** own sleep sounds (rain, waves, deep noise) with a sleep timer that fades out, and a sunrise option that slowly brightens the screen 15 minutes before the wake-up alarm. +- **Coming next:** widget style options like Wetter, screensaver/night clock.

Alarm list diff --git a/replica/uhr/build-log.md b/replica/uhr/build-log.md index 69ecce7..65372cc 100644 --- a/replica/uhr/build-log.md +++ b/replica/uhr/build-log.md @@ -11,11 +11,11 @@ Device checks on a Pixel 10 Pro XL (Android 16) via a debug-only activity that s | S03 Time picker | 2026-10-17 | done | – | – | | S04 Sound picker | 2026-10-17 | done | – | own file not device-tested | | S05 Pause alarm | 2026-10-17 | done | – | – | -| S06 Clock | 2026-10-17 | done | reorder cities | city names offline via ICU exemplar cities | +| S06 Clock | 2026-10-17 | done | – | city names offline via ICU exemplar cities | | S07 Add city | 2026-10-17 | done | – | – | -| S08 Timer | 2026-10-17 | done | label edit UI | verified on device: 5 s timer rang in the background, stopped from the notification | +| S08 Timer | 2026-10-17 | done | – | verified on device: 5 s timer rang in the background, stopped from the notification | | S09 Stopwatch | 2026-10-17 | done | – | – | -| S10 Bedtime | 2026-10-17 | partial | sunrise alarm, sleep sounds | – | +| S10 Bedtime | 2026-10-17 | done | sunrise full-screen not confirmed on device | sleep sounds generated in-house; the sunrise needs the full-screen-intent permission, which Android 14+ does not grant sideloaded apps by default (the app asks) | | S11 Ringing | 2026-10-17 | done | – | – | | S12 Timer ringing | 2026-10-17 | done | – | shares the ringing screen | | S13 Settings | 2026-10-17 | done | timer sound picker, home time zone | – | diff --git a/replica/uhr/clone-screens/S06-analog-light.png b/replica/uhr/clone-screens/S06-analog-light.png index 531ed1e..e6c01c1 100644 Binary files a/replica/uhr/clone-screens/S06-analog-light.png and b/replica/uhr/clone-screens/S06-analog-light.png differ diff --git a/replica/uhr/clone-screens/S06-clock-dark.png b/replica/uhr/clone-screens/S06-clock-dark.png index c7200d4..77549b3 100644 Binary files a/replica/uhr/clone-screens/S06-clock-dark.png and b/replica/uhr/clone-screens/S06-clock-dark.png differ diff --git a/replica/uhr/clone-screens/S08-timers-dark.png b/replica/uhr/clone-screens/S08-timers-dark.png index 496c460..d078ea3 100644 Binary files a/replica/uhr/clone-screens/S08-timers-dark.png and b/replica/uhr/clone-screens/S08-timers-dark.png differ diff --git a/replica/uhr/clone-screens/S10-bedtime-dark.png b/replica/uhr/clone-screens/S10-bedtime-dark.png index da9c796..744f0c7 100644 Binary files a/replica/uhr/clone-screens/S10-bedtime-dark.png and b/replica/uhr/clone-screens/S10-bedtime-dark.png differ diff --git a/replica/uhr/clone-screens/S11-ringing.png b/replica/uhr/clone-screens/S11-ringing.png index 81315ca..e1107da 100644 Binary files a/replica/uhr/clone-screens/S11-ringing.png and b/replica/uhr/clone-screens/S11-ringing.png differ diff --git a/replica/uhr/design/Tokens.kt b/replica/uhr/design/Tokens.kt index efa007b..bea263f 100644 --- a/replica/uhr/design/Tokens.kt +++ b/replica/uhr/design/Tokens.kt @@ -65,6 +65,10 @@ object WColor { val timerProgress = Color(0xFF7EC4A8) val nightText = Color(0xFFC4433A) val nightBg = Color(0xFF000000) + val sunriseNight = Color(0xFF0D0710) + val sunriseEmber = Color(0xFF5A1A10) + val sunriseDay = Color(0xFFFFD9A0) + val sunriseSun = Color(0xFFFFF3D6) } object WType { diff --git a/replica/uhr/design/tokens.json b/replica/uhr/design/tokens.json index a5f6089..1534752 100644 --- a/replica/uhr/design/tokens.json +++ b/replica/uhr/design/tokens.json @@ -1,45 +1,230 @@ { "_about": "Uhr tokens. Roles read by eye from replica/uhr/screens/ref-01..07 (Pixel Clock, dark first). Own values, no brand colour copied; on Android 12+ dynamic colour (wallpaper) replaces the accent roles. Check: python3 ~/.claude/skills/replica-design/contrast.py replica/uhr/design/tokens.json", "color": { - "bg": "#f8f9ff", "surface": "#ffffff", "card": "#eef0f6", "card-high": "#e2e5ec", - "border": "#c3c6cf", "border-input": "#73777f", - "text": "#191c20", "text-muted": "#43474e", "text-off": "#5c6068", - "accent": "#2f5ea8", "on-accent": "#ffffff", - "accent-container": "#d6e3ff", "on-accent-container": "#001b3e", - "fab": "#a8c7fa", "on-fab": "#0b2a55", - "chip-on": "#2f5ea8", "on-chip-on": "#ffffff", - "secondary-pill": "#dfe2eb", "on-secondary-pill": "#191c20", - "stop": "#ffd8e4", "on-stop": "#4a1426", + "bg": "#f8f9ff", + "surface": "#ffffff", + "card": "#eef0f6", + "card-high": "#e2e5ec", + "border": "#c3c6cf", + "border-input": "#73777f", + "text": "#191c20", + "text-muted": "#43474e", + "text-off": "#5c6068", + "accent": "#2f5ea8", + "on-accent": "#ffffff", + "accent-container": "#d6e3ff", + "on-accent-container": "#001b3e", + "fab": "#a8c7fa", + "on-fab": "#0b2a55", + "chip-on": "#2f5ea8", + "on-chip-on": "#ffffff", + "secondary-pill": "#dfe2eb", + "on-secondary-pill": "#191c20", + "stop": "#ffd8e4", + "on-stop": "#4a1426", "danger": "#ba1a1a", - "nav": "#eceef4", "nav-indicator": "#d6e3ff", - - "dark-bg": "#111318", "dark-surface": "#111318", "dark-card": "#1d2024", "dark-card-high": "#272a2f", - "dark-border": "#43474e", "dark-border-input": "#8d9199", - "dark-text": "#e2e2e9", "dark-text-muted": "#c3c6cf", "dark-text-off": "#9a9ea6", - "dark-accent": "#a8c7fa", "dark-on-accent": "#062e6f", - "dark-accent-container": "#244a80", "dark-on-accent-container": "#d6e3ff", - "dark-fab": "#a8c7fa", "dark-on-fab": "#062e6f", - "dark-chip-on": "#a8c7fa", "dark-on-chip-on": "#062e6f", - "dark-secondary-pill": "#3a3d43", "dark-on-secondary-pill": "#e2e2e9", - "dark-stop": "#f2b8c9", "dark-on-stop": "#4a1426", + "nav": "#eceef4", + "nav-indicator": "#d6e3ff", + "dark-bg": "#111318", + "dark-surface": "#111318", + "dark-card": "#1d2024", + "dark-card-high": "#272a2f", + "dark-border": "#43474e", + "dark-border-input": "#8d9199", + "dark-text": "#e2e2e9", + "dark-text-muted": "#c3c6cf", + "dark-text-off": "#9a9ea6", + "dark-accent": "#a8c7fa", + "dark-on-accent": "#062e6f", + "dark-accent-container": "#244a80", + "dark-on-accent-container": "#d6e3ff", + "dark-fab": "#a8c7fa", + "dark-on-fab": "#062e6f", + "dark-chip-on": "#a8c7fa", + "dark-on-chip-on": "#062e6f", + "dark-secondary-pill": "#3a3d43", + "dark-on-secondary-pill": "#e2e2e9", + "dark-stop": "#f2b8c9", + "dark-on-stop": "#4a1426", "dark-danger": "#ffb4ab", - "dark-nav": "#1d2024", "dark-nav-indicator": "#3a3f47", - - "ring-bg": "#1e5a52", "ring-on": "#b4f1e6", "ring-track": "#2b6e65", "ring-thumb": "#3f9a7f", "on-ring-thumb": "#ffffff", - "timer-track": "#3a3d43", "timer-progress": "#7ec4a8", - "night-text": "#c4433a", "night-bg": "#000000" + "dark-nav": "#1d2024", + "dark-nav-indicator": "#3a3f47", + "ring-bg": "#1e5a52", + "ring-on": "#b4f1e6", + "ring-track": "#2b6e65", + "ring-thumb": "#3f9a7f", + "on-ring-thumb": "#ffffff", + "timer-track": "#3a3d43", + "timer-progress": "#7ec4a8", + "night-text": "#c4433a", + "night-bg": "#000000", + "sunrise-night": "#0d0710", + "sunrise-ember": "#5a1a10", + "sunrise-day": "#ffd9a0", + "sunrise-sun": "#fff3d6" }, "pairs": [ - ["text","bg"],["text","card"],["text","card-high"],["text-muted","bg"],["text-muted","card"],["text-off","card"], - ["on-accent","accent"],["on-accent-container","accent-container"],["on-fab","fab"],["on-chip-on","chip-on"], - ["on-secondary-pill","secondary-pill"],["on-stop","stop"],["accent","card"],["danger","card"],["text","nav"],["text","nav-indicator"], - ["border-input","surface","ui"], - ["dark-text","dark-bg"],["dark-text","dark-card"],["dark-text","dark-card-high"],["dark-text-muted","dark-bg"],["dark-text-muted","dark-card"],["dark-text-off","dark-card"], - ["dark-on-accent","dark-accent"],["dark-on-accent-container","dark-accent-container"],["dark-on-fab","dark-fab"],["dark-on-chip-on","dark-chip-on"], - ["dark-on-secondary-pill","dark-secondary-pill"],["dark-on-stop","dark-stop"],["dark-accent","dark-card"],["dark-danger","dark-card"], - ["dark-text","dark-nav"],["dark-text","dark-nav-indicator"],["dark-border-input","dark-card","ui"], - ["ring-on","ring-bg"],["ring-on","ring-track"],["on-ring-thumb","ring-thumb","large"], - ["timer-progress","dark-card","ui"],["night-text","night-bg","large"] + [ + "text", + "bg" + ], + [ + "text", + "card" + ], + [ + "text", + "card-high" + ], + [ + "text-muted", + "bg" + ], + [ + "text-muted", + "card" + ], + [ + "text-off", + "card" + ], + [ + "on-accent", + "accent" + ], + [ + "on-accent-container", + "accent-container" + ], + [ + "on-fab", + "fab" + ], + [ + "on-chip-on", + "chip-on" + ], + [ + "on-secondary-pill", + "secondary-pill" + ], + [ + "on-stop", + "stop" + ], + [ + "accent", + "card" + ], + [ + "danger", + "card" + ], + [ + "text", + "nav" + ], + [ + "text", + "nav-indicator" + ], + [ + "border-input", + "surface", + "ui" + ], + [ + "dark-text", + "dark-bg" + ], + [ + "dark-text", + "dark-card" + ], + [ + "dark-text", + "dark-card-high" + ], + [ + "dark-text-muted", + "dark-bg" + ], + [ + "dark-text-muted", + "dark-card" + ], + [ + "dark-text-off", + "dark-card" + ], + [ + "dark-on-accent", + "dark-accent" + ], + [ + "dark-on-accent-container", + "dark-accent-container" + ], + [ + "dark-on-fab", + "dark-fab" + ], + [ + "dark-on-chip-on", + "dark-chip-on" + ], + [ + "dark-on-secondary-pill", + "dark-secondary-pill" + ], + [ + "dark-on-stop", + "dark-stop" + ], + [ + "dark-accent", + "dark-card" + ], + [ + "dark-danger", + "dark-card" + ], + [ + "dark-text", + "dark-nav" + ], + [ + "dark-text", + "dark-nav-indicator" + ], + [ + "dark-border-input", + "dark-card", + "ui" + ], + [ + "ring-on", + "ring-bg" + ], + [ + "ring-on", + "ring-track" + ], + [ + "on-ring-thumb", + "ring-thumb", + "large" + ], + [ + "timer-progress", + "dark-card", + "ui" + ], + [ + "night-text", + "night-bg", + "large" + ] ], "font": { "sans": "Roboto Flex (OFL), system fallback", @@ -48,21 +233,88 @@ "mono": "tabular figures (fontFeatureSettings tnum) instead of a mono font for the stopwatch" }, "type": { - "label-sm": {"size": 12, "line": 16, "weight": 500}, - "label": {"size": 14, "line": 20, "weight": 500}, - "body-sm": {"size": 14, "line": 20, "weight": 400}, - "body": {"size": 16, "line": 24, "weight": 400}, - "title": {"size": 22, "line": 28, "weight": 400}, - "headline": {"size": 28, "line": 36, "weight": 400}, - "alarm-time": {"size": 56, "line": 60, "weight": 600}, - "alarm-ampm": {"size": 20, "line": 24, "weight": 600}, - "timer-time": {"size": 44, "line": 48, "weight": 400}, - "clock-big": {"size": 88, "line": 92, "weight": 400}, - "stopwatch": {"size": 72, "line": 76, "weight": 500}, - "ringing": {"size": 104, "line": 108, "weight": 400} + "label-sm": { + "size": 12, + "line": 16, + "weight": 500 + }, + "label": { + "size": 14, + "line": 20, + "weight": 500 + }, + "body-sm": { + "size": 14, + "line": 20, + "weight": 400 + }, + "body": { + "size": 16, + "line": 24, + "weight": 400 + }, + "title": { + "size": 22, + "line": 28, + "weight": 400 + }, + "headline": { + "size": 28, + "line": 36, + "weight": 400 + }, + "alarm-time": { + "size": 56, + "line": 60, + "weight": 600 + }, + "alarm-ampm": { + "size": 20, + "line": 24, + "weight": 600 + }, + "timer-time": { + "size": 44, + "line": 48, + "weight": 400 + }, + "clock-big": { + "size": 88, + "line": 92, + "weight": 400 + }, + "stopwatch": { + "size": 72, + "line": 76, + "weight": 500 + }, + "ringing": { + "size": 104, + "line": 108, + "weight": 400 + } + }, + "space": [ + 0, + 2, + 4, + 8, + 12, + 16, + 20, + 24, + 32, + 48 + ], + "radius": { + "xs": 8, + "sm": 12, + "md": 16, + "lg": 24, + "card": 28, + "pill": 999, + "nav-indicator": 16 }, - "space": [0, 2, 4, 8, 12, 16, 20, 24, 32, 48], - "radius": {"xs": 8, "sm": 12, "md": 16, "lg": 24, "card": 28, "pill": 999, "nav-indicator": 16}, "shape": { "fab": "circle 96dp (large, centred above the nav bar)", "stopwatch-primary": "pill 72dp tall, full width", @@ -72,7 +324,14 @@ "motion": { "card-expand": "spring dampingRatio 0.85 stiffness 380", "slide-act": "spring back dampingRatio 0.7", - "fast": "120ms", "base": "250ms" + "fast": "120ms", + "base": "250ms" }, - "layout": {"gutter": 16, "card-gap": 8, "nav-height": 80, "fab-bottom": 16, "card-padding": 20} -} + "layout": { + "gutter": 16, + "card-gap": 8, + "nav-height": 80, + "fab-bottom": 16, + "card-padding": 20 + } +} \ No newline at end of file diff --git a/replica/uhr/features.csv b/replica/uhr/features.csv index 3ba1921..db7102b 100644 --- a/replica/uhr/features.csv +++ b/replica/uhr/features.csv @@ -24,14 +24,14 @@ Missed alarm notification,notifications,should,yes,yes, Snoozed notification with dismiss,notifications,must,yes,yes, World clock: local time and date,clock,must,yes,yes, World clock: add city via search,clock,must,yes,yes, -World clock: reorder and swipe to delete,clock,should,yes,partial,swipe to delete only +World clock: reorder and swipe to delete,clock,should,yes,yes,"long-press drag; verified on device, order persists" Analog or digital clock style,clock,should,yes,yes, Show seconds,clock,could,yes,yes, Automatic home clock when travelling,clock,could,yes,no, Timer keypad h m s,timer,must,yes,yes, Run several timers at once,timer,must,yes,yes, Timer ring progress +1:00 pause reset delete,timer,must,yes,yes, -Timer labels,timer,could,yes,partial,stored and shown; no UI to edit yet +Timer labels,timer,could,yes,yes,tap the title to rename Timer runs in background with notification,timer,must,yes,yes, Timer ringing screen,timer,must,yes,yes, Timer sound and vibrate settings,settings,should,yes,partial,vibrate setting only; sound uses default @@ -40,8 +40,8 @@ Stopwatch laps,stopwatch,must,yes,yes, Stopwatch running notification,stopwatch,should,yes,yes, Bedtime schedule with wake-up alarm,bedtime,should,yes,yes, Bedtime reminder notification,bedtime,should,yes,yes, -Sunrise alarm (brightening screen),bedtime,could,yes,no, -Sleep sounds with timer,bedtime,could,yes,no,own CC0 sounds +Sunrise alarm (brightening screen),bedtime,could,yes,partial,scheduled 15 min before the bedtime wake alarm via full-screen notification; full-screen launch not confirmed on device (no keyguard during test) +Sleep sounds with timer,bedtime,could,yes,yes,"own generated sounds (rain, waves, brown noise), sleep timer with 20 s fade-out; verified playing on device" Screensaver / bedside clock with night mode,display,could,yes,no,DreamService Digital clock widget,widgets,must,yes,yes, Analog clock widget,widgets,should,yes,yes, diff --git a/uhr/app/build.gradle.kts b/uhr/app/build.gradle.kts index 7103558..3639ede 100644 --- a/uhr/app/build.gradle.kts +++ b/uhr/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "app.uhr" minSdk = 29 targetSdk = 36 - versionCode = 3 - versionName = "0.1.2" + versionCode = 4 + versionName = "0.2.0" } // Release key lives outside the repo; without it, release builds fall back to the debug key (local only). diff --git a/uhr/app/src/debug/java/app/uhr/DebugAlarmActivity.kt b/uhr/app/src/debug/java/app/uhr/DebugAlarmActivity.kt index 5164632..f80ade1 100644 --- a/uhr/app/src/debug/java/app/uhr/DebugAlarmActivity.kt +++ b/uhr/app/src/debug/java/app/uhr/DebugAlarmActivity.kt @@ -13,6 +13,15 @@ class DebugAlarmActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Graph.init(this) + if (intent.getBooleanExtra("sunrise", false)) { + // through the real path: AlarmManager -> receiver -> full-screen notification, 20 s from now + Graph.repo.scheduler.scheduleSunrise(System.currentTimeMillis() + app.uhr.alarm.SunriseActivity.DURATION_MS + 20_000L) + finish(); return + } + if (intent.getBooleanExtra("cities", false)) { + runBlocking { app.uhr.data.Zones.search("").let { } ; listOf("Tokyo", "New_York", "London").forEach { c -> app.uhr.data.Zones.search(c.replace('_', ' ')).firstOrNull()?.let { Graph.repo.addCity(it) } } } + finish(); return + } val timerS = intent.getIntExtra("timer_seconds", 0) if (timerS > 0) { val id = runBlocking { Graph.repo.startTimer(timerS * 1000L, "Testtimer") } diff --git a/uhr/app/src/main/AndroidManifest.xml b/uhr/app/src/main/AndroidManifest.xml index a21028c..3076593 100644 --- a/uhr/app/src/main/AndroidManifest.xml +++ b/uhr/app/src/main/AndroidManifest.xml @@ -37,6 +37,9 @@ android:turnScreenOn="true" android:taskAffinity="" /> + + diff --git a/uhr/app/src/main/java/app/uhr/MainActivity.kt b/uhr/app/src/main/java/app/uhr/MainActivity.kt index 815137b..d694158 100644 --- a/uhr/app/src/main/java/app/uhr/MainActivity.kt +++ b/uhr/app/src/main/java/app/uhr/MainActivity.kt @@ -164,6 +164,7 @@ private fun ClockApp(settings: Settings, requestedTab: Int) { override fun reset(id: Long) { scope.launch { repo.resetTimer(id) } } override fun delete(id: Long) { scope.launch { repo.deleteTimer(id) } } override fun stopRinging() { RingingService.stop(context) } + override fun rename(id: Long, label: String) { scope.launch { repo.setTimerLabel(id, label) } } } } @@ -209,7 +210,7 @@ private fun ClockApp(settings: Settings, requestedTab: Int) { ) { pad -> when (tab) { 0 -> AlarmScreen(alarms, settings, alarmActions, snackbar, pad) - 1 -> ClockScreen(settings, cities, next, pad, onDeleteCity = { c -> scope.launch { repo.deleteCity(c.id) } }) + 1 -> ClockScreen(settings, cities, next, pad, onDeleteCity = { c -> scope.launch { repo.deleteCity(c.id) } }, onReorder = { ids -> scope.launch { repo.reorderCities(ids) } }) 2 -> { if (keypad && timers.isNotEmpty()) BackHandler { keypad = false; digits = "" } TimerScreen(timers, keypad, digits, { digits = it }, timerActions, pad) diff --git a/uhr/app/src/main/java/app/uhr/alarm/AlarmReceiver.kt b/uhr/app/src/main/java/app/uhr/alarm/AlarmReceiver.kt index 4a43724..4ce8818 100644 --- a/uhr/app/src/main/java/app/uhr/alarm/AlarmReceiver.kt +++ b/uhr/app/src/main/java/app/uhr/alarm/AlarmReceiver.kt @@ -31,12 +31,13 @@ class AlarmReceiver : BroadcastReceiver() { val repo = Graph.repo when (action) { Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_LOCKED_BOOT_COMPLETED, Intent.ACTION_TIME_CHANGED, "android.intent.action.TIME_SET", - Intent.ACTION_TIMEZONE_CHANGED, Intent.ACTION_MY_PACKAGE_REPLACED -> repo.scheduler.rescheduleAll() + Intent.ACTION_TIMEZONE_CHANGED, Intent.ACTION_MY_PACKAGE_REPLACED -> { repo.scheduler.rescheduleAll(); repo.scheduleBedtime() } ACTION_UPCOMING -> repo.alarm(id)?.let { a -> repo.instance(id)?.let { Notifications.upcoming(context, a, it.fireAt) } } ACTION_DISMISS_NEXT -> repo.dismissNext(id) ACTION_SNOOZE_DISMISS -> repo.finishAlarm(id) ACTION_TIMER_PAUSE -> repo.pauseTimer(id) ACTION_TIMER_ADD_MINUTE -> repo.addMinute(id) + ACTION_SUNRISE -> Notifications.sunrise(context, id) ACTION_BEDTIME -> { Notifications.bedtime(context); repo.scheduleBedtime() } } } @@ -51,6 +52,7 @@ class AlarmReceiver : BroadcastReceiver() { const val ACTION_TIMER_PAUSE = "app.uhr.TIMER_PAUSE" const val ACTION_TIMER_ADD_MINUTE = "app.uhr.TIMER_ADD_MINUTE" const val ACTION_BEDTIME = "app.uhr.BEDTIME" + const val ACTION_SUNRISE = "app.uhr.SUNRISE" private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) } } diff --git a/uhr/app/src/main/java/app/uhr/alarm/Notifications.kt b/uhr/app/src/main/java/app/uhr/alarm/Notifications.kt index 7bdbe27..5212200 100644 --- a/uhr/app/src/main/java/app/uhr/alarm/Notifications.kt +++ b/uhr/app/src/main/java/app/uhr/alarm/Notifications.kt @@ -32,6 +32,8 @@ object Notifications { const val ID_STOPWATCH = 2 const val ID_RINGING = 3 const val ID_BEDTIME = 4 + const val ID_SLEEP = 5 + const val ID_SUNRISE = 6 private const val ALARM_BASE = 1000 fun createChannels(context: Context) { @@ -147,6 +149,26 @@ object Notifications { ) } + /** Full-screen intent is the only way to bring the sunrise screen up from the background. */ + fun sunrise(context: Context, fireAt: Long) { + if (!allowed(context)) return + val full = PendingIntent.getActivity( + context, 7, Intent(context, SunriseActivity::class.java).putExtra(SunriseActivity.EXTRA_FIRE_AT, fireAt) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + // an update of an existing notification does not fire its full-screen intent again + nm(context).cancel(ID_SUNRISE) + nm(context).notify( + ID_SUNRISE, + NotificationCompat.Builder(context, CH_RINGING).setSmallIcon(R.drawable.ic_stat_alarm) + .setContentTitle(context.getString(R.string.sunrise_running)).setSilent(true) + .setCategory(NotificationCompat.CATEGORY_ALARM).setPriority(NotificationCompat.PRIORITY_MAX) + .setFullScreenIntent(full, true).setContentIntent(full).setTimeoutAfter(SunriseActivity.DURATION_MS + 60_000) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC).build(), + ) + } + fun ringing(context: Context, title: String, text: String, fullScreen: PendingIntent, stop: PendingIntent, snooze: PendingIntent?): Notification = NotificationCompat.Builder(context, CH_RINGING).setSmallIcon(R.drawable.ic_stat_alarm) .setContentTitle(title).setContentText(text) diff --git a/uhr/app/src/main/java/app/uhr/alarm/Scheduler.kt b/uhr/app/src/main/java/app/uhr/alarm/Scheduler.kt index 9607f3e..3d435e2 100644 --- a/uhr/app/src/main/java/app/uhr/alarm/Scheduler.kt +++ b/uhr/app/src/main/java/app/uhr/alarm/Scheduler.kt @@ -71,6 +71,20 @@ class Scheduler(private val context: Context, private val dao: ClockDao) { fun scheduleBedtimeReminder(at: Long) = am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, broadcast(AlarmReceiver.ACTION_BEDTIME, 0, REQ_BEDTIME)) + fun scheduleSunrise(fireAt: Long) { + val at = fireAt - SunriseActivity.DURATION_MS + if (at > System.currentTimeMillis()) am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, sunriseIntent(fireAt)) + } + + fun cancelSunrise() = am.cancel(sunriseIntent(0)) + + /** One sunrise at a time (fixed request code); the wake-up time travels as an extra. */ + private fun sunriseIntent(fireAt: Long) = PendingIntent.getBroadcast( + context, REQ_SUNRISE, + Intent(context, AlarmReceiver::class.java).setAction(AlarmReceiver.ACTION_SUNRISE).putExtra(AlarmReceiver.EXTRA_ID, fireAt), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + fun cancelBedtimeReminder() = am.cancel(broadcast(AlarmReceiver.ACTION_BEDTIME, 0, REQ_BEDTIME)) private fun ringIntent(id: Long) = broadcast(AlarmReceiver.ACTION_RING, id, REQ_RING) @@ -89,5 +103,6 @@ class Scheduler(private val context: Context, private val dao: ClockDao) { private const val REQ_UPCOMING = 1_000_000 private const val REQ_TIMER = 2_000_000 private const val REQ_BEDTIME = 3_000_000 + private const val REQ_SUNRISE = 4_000_000 } } diff --git a/uhr/app/src/main/java/app/uhr/alarm/SleepSoundService.kt b/uhr/app/src/main/java/app/uhr/alarm/SleepSoundService.kt new file mode 100644 index 0000000..c694387 --- /dev/null +++ b/uhr/app/src/main/java/app/uhr/alarm/SleepSoundService.kt @@ -0,0 +1,109 @@ +package app.uhr.alarm + +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.media.AudioAttributes +import android.media.MediaPlayer +import android.net.Uri +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import app.uhr.MainActivity +import app.uhr.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +enum class SleepSound(val res: Int, val title: Int) { + RAIN(R.raw.sleep_rain, R.string.sound_rain), + WAVES(R.raw.sleep_waves, R.string.sound_waves), + BROWN(R.raw.sleep_brown, R.string.sound_brown), +} + +data class SleepPlayback(val sound: SleepSound, val endsAt: Long) + +/** Plays a looping sleep sound on the media stream and fades it out at the end of the sleep timer. */ +class SleepSoundService : Service() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private var player: MediaPlayer? = null + private var job: Job? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_STOP) { stop(); return START_NOT_STICKY } + val sound = SleepSound.entries.getOrNull(intent?.getIntExtra(EXTRA_SOUND, 0) ?: 0) ?: SleepSound.RAIN + val minutes = intent?.getIntExtra(EXTRA_MINUTES, 30) ?: 30 + val endsAt = System.currentTimeMillis() + minutes * 60_000L + ServiceCompat.startForeground(this, Notifications.ID_SLEEP, notification(sound, endsAt), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) + player?.release() + player = MediaPlayer().apply { + setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build()) + setDataSource(this@SleepSoundService, Uri.parse("android.resource://$packageName/${sound.res}")) + isLooping = true + prepare() + setVolume(0f, 0f) + start() + } + state.value = SleepPlayback(sound, endsAt) + job?.cancel() + job = scope.launch { + fade(0f, 1f, 3_000) + delay((endsAt - System.currentTimeMillis() - FADE_OUT_MS).coerceAtLeast(0)) + fade(1f, 0f, FADE_OUT_MS) + stop() + } + return START_NOT_STICKY + } + + private suspend fun fade(from: Float, to: Float, ms: Long) { + val steps = (ms / 100).toInt().coerceAtLeast(1) + for (i in 1..steps) { + val v = from + (to - from) * i / steps + runCatching { player?.setVolume(v, v) } + delay(100) + } + } + + private fun notification(sound: SleepSound, endsAt: Long) = + NotificationCompat.Builder(this, Notifications.CH_RUNNING).setSmallIcon(R.drawable.ic_stat_bedtime) + .setContentTitle(getString(sound.title)).setContentText(getString(R.string.sleep_sound_running)) + .setUsesChronometer(true).setChronometerCountDown(true).setWhen(endsAt).setShowWhen(true) + .setOngoing(true).setSilent(true) + .setContentIntent(PendingIntent.getActivity(this, 5, Intent(this, MainActivity::class.java).putExtra(MainActivity.EXTRA_TAB, 4), PendingIntent.FLAG_IMMUTABLE)) + .addAction(0, getString(R.string.stop), PendingIntent.getService(this, 6, Intent(this, SleepSoundService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_IMMUTABLE)) + .build() + + private fun stop() { + job?.cancel() + player?.runCatching { stop(); release() }; player = null + state.value = null + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf() + } + + override fun onDestroy() { player?.release(); state.value = null; scope.cancel(); super.onDestroy() } + + companion object { + private const val ACTION_STOP = "stop" + private const val EXTRA_SOUND = "sound" + private const val EXTRA_MINUTES = "minutes" + private const val FADE_OUT_MS = 20_000L + private val state = MutableStateFlow(null) + val playing: StateFlow get() = state + + fun start(context: Context, sound: SleepSound, minutes: Int) = context.startForegroundService( + Intent(context, SleepSoundService::class.java).putExtra(EXTRA_SOUND, sound.ordinal).putExtra(EXTRA_MINUTES, minutes), + ) + fun stop(context: Context) = context.startService(Intent(context, SleepSoundService::class.java).setAction(ACTION_STOP)) + } +} diff --git a/uhr/app/src/main/java/app/uhr/alarm/SunriseActivity.kt b/uhr/app/src/main/java/app/uhr/alarm/SunriseActivity.kt new file mode 100644 index 0000000..ac74da1 --- /dev/null +++ b/uhr/app/src/main/java/app/uhr/alarm/SunriseActivity.kt @@ -0,0 +1,62 @@ +package app.uhr.alarm + +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.lerp +import app.uhr.ui.theme.WColor +import kotlinx.coroutines.delay + +/** + * Sunrise alarm: in the last minutes before the wake-up alarm the screen turns on over the lock screen + * and brightens slowly from a dim red glow to warm daylight. Tap to dismiss; the alarm still rings. + */ +class SunriseActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setShowWhenLocked(true) + setTurnScreenOn(true) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + val endsAt = intent.getLongExtra(EXTRA_FIRE_AT, System.currentTimeMillis() + DURATION_MS) + val startedAt = endsAt - DURATION_MS + setContent { + val progress = remember { mutableFloatStateOf(0f) } + val ringing by RingingService.ringing.collectAsState() + LaunchedEffect(Unit) { + while (true) { + val p = ((System.currentTimeMillis() - startedAt).toFloat() / DURATION_MS).coerceIn(0f, 1f) + progress.floatValue = p + // the window brightness follows the colour, from almost off to full + window.attributes = window.attributes.apply { screenBrightness = 0.01f + 0.99f * p * p } + delay(500) + } + } + // the ringing screen takes over when the alarm starts + LaunchedEffect(ringing) { if (ringing != null) finish() } + val p = progress.floatValue + Canvas(Modifier.fillMaxSize().clickable { finish() }) { + val top = lerp(WColor.sunriseNight, WColor.sunriseDay, p) + val glow = lerp(WColor.sunriseEmber, WColor.sunriseSun, p) + drawRect(Brush.verticalGradient(listOf(top, glow))) + drawCircle(Brush.radialGradient(listOf(glow.copy(alpha = .9f), glow.copy(alpha = 0f)), Offset(size.width / 2, size.height), size.width * (0.4f + p)), size.width * (0.4f + p), Offset(size.width / 2, size.height)) + } + } + } + + companion object { + const val EXTRA_FIRE_AT = "fire_at" + const val DURATION_MS = 15 * 60_000L + } +} diff --git a/uhr/app/src/main/java/app/uhr/data/ClockRepo.kt b/uhr/app/src/main/java/app/uhr/data/ClockRepo.kt index d3fbae2..8d53df2 100644 --- a/uhr/app/src/main/java/app/uhr/data/ClockRepo.kt +++ b/uhr/app/src/main/java/app/uhr/data/ClockRepo.kt @@ -28,10 +28,11 @@ class ClockRepo(private val context: Context, private val dao: ClockDao, val pre suspend fun save(alarm: Alarm): Long = lock.withLock { val stamped = alarm.copy(updatedAt = System.currentTimeMillis()) val id = if (alarm.id == 0L) dao.insert(stamped) else stamped.id.also { dao.update(stamped) } - dao.alarm(id)?.let { scheduler.reschedule(it) } + val saved = dao.alarm(id) + saved?.let { scheduler.reschedule(it) } updateClockWidgets(context) id - } + }.also { id -> if (dao.alarm(id)?.isBedtimeWake == true) scheduleBedtime() } suspend fun delete(alarm: Alarm) = lock.withLock { scheduler.cancel(alarm.id) @@ -182,8 +183,15 @@ class ClockRepo(private val context: Context, private val dao: ClockDao, val pre scheduleBedtime() } + suspend fun bedtimeAlarmFireAt(): Long? { + val a = dao.bedtimeAlarm()?.takeIf { it.enabled } ?: return null + return dao.instance(a.id)?.takeIf { it.state == InstanceState.SCHEDULED }?.fireAt + } + suspend fun scheduleBedtime() { val b = prefs.bedtime.first() + scheduler.cancelSunrise() + if (b.enabled && b.sunrise) bedtimeAlarmFireAt()?.let { scheduler.scheduleSunrise(it) } scheduler.cancelBedtimeReminder() if (!b.enabled || b.reminderMin < 0) return val reminder = Alarm(hour = b.bedHour, minute = b.bedMinute, days = b.days) diff --git a/uhr/app/src/main/java/app/uhr/data/Prefs.kt b/uhr/app/src/main/java/app/uhr/data/Prefs.kt index 35cdbda..d23efe1 100644 --- a/uhr/app/src/main/java/app/uhr/data/Prefs.kt +++ b/uhr/app/src/main/java/app/uhr/data/Prefs.kt @@ -55,6 +55,7 @@ data class Bedtime( val wakeMinute: Int = 0, val days: Int = Alarm.WEEKDAYS, val reminderMin: Int = 15, + val sunrise: Boolean = false, ) private val Context.store by preferencesDataStore("clock") @@ -85,6 +86,7 @@ class Prefs(private val context: Context) { val wakeM = intPreferencesKey("wake_m") val bedDays = intPreferencesKey("bed_days") val bedReminder = intPreferencesKey("bed_reminder") + val sunrise = booleanPreferencesKey("sunrise") } private fun Preferences.settings(): Settings { @@ -142,14 +144,14 @@ class Prefs(private val context: Context) { Bedtime( enabled = p[K.bedOn] ?: d.enabled, bedHour = p[K.bedH] ?: d.bedHour, bedMinute = p[K.bedM] ?: d.bedMinute, wakeHour = p[K.wakeH] ?: d.wakeHour, wakeMinute = p[K.wakeM] ?: d.wakeMinute, - days = p[K.bedDays] ?: d.days, reminderMin = p[K.bedReminder] ?: d.reminderMin, + days = p[K.bedDays] ?: d.days, reminderMin = p[K.bedReminder] ?: d.reminderMin, sunrise = p[K.sunrise] ?: d.sunrise, ) } suspend fun setBedtime(b: Bedtime) { context.store.edit { p -> p[K.bedOn] = b.enabled; p[K.bedH] = b.bedHour; p[K.bedM] = b.bedMinute; p[K.wakeH] = b.wakeHour - p[K.wakeM] = b.wakeMinute; p[K.bedDays] = b.days; p[K.bedReminder] = b.reminderMin + p[K.wakeM] = b.wakeMinute; p[K.bedDays] = b.days; p[K.bedReminder] = b.reminderMin; p[K.sunrise] = b.sunrise } } } diff --git a/uhr/app/src/main/java/app/uhr/ui/screens/BedtimeScreen.kt b/uhr/app/src/main/java/app/uhr/ui/screens/BedtimeScreen.kt index 53b78ef..663d0bc 100644 --- a/uhr/app/src/main/java/app/uhr/ui/screens/BedtimeScreen.kt +++ b/uhr/app/src/main/java/app/uhr/ui/screens/BedtimeScreen.kt @@ -22,6 +22,14 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Bedtime import androidx.compose.material.icons.outlined.NotificationsActive import androidx.compose.material.icons.outlined.WbSunny +import androidx.compose.material.icons.outlined.WbTwilight +import androidx.compose.material.icons.outlined.Waves +import androidx.compose.material.icons.rounded.Stop +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.LocalContext +import app.uhr.alarm.SleepSound +import app.uhr.alarm.SleepSoundService import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -102,7 +110,9 @@ fun BedtimeScreen(b: Bedtime, settings: Settings, onSave: (Bedtime) -> Unit, con { m -> if (m < 0) stringResource(R.string.off) else if (m == 0) stringResource(R.string.at_bedtime) else stringResource(R.string.minutes_before, m) }, { m -> onSave(b.copy(reminderMin = m)) }, Icons.Outlined.NotificationsActive) } + item { SettingsSwitch(it, stringResource(R.string.sunrise_alarm), b.sunrise, { v -> onSave(b.copy(sunrise = v)) }, stringResource(R.string.sunrise_alarm_sub), Icons.Outlined.WbTwilight) } } + SleepSounds() Spacer(Modifier.height(120.dp)) } if (pickBed) TimeDialog(b.bedHour, b.bedMinute, { pickBed = false }) { h, m -> pickBed = false; onSave(b.copy(bedHour = h, bedMinute = m)) } @@ -122,3 +132,29 @@ private fun TimeTile(icon: androidx.compose.ui.graphics.vector.ImageVector, labe Text(time, style = tabular(WType.timerTime)) } } + +/** Own looping sounds with a sleep timer; they fade out at the end. */ +@Composable +private fun SleepSounds() { + val context = LocalContext.current + val playing by SleepSoundService.playing.collectAsState() + var sound by remember { mutableStateOf(playing?.sound ?: SleepSound.RAIN) } + var minutes by remember { mutableStateOf(30) } + SettingsGroup(stringResource(R.string.sleep_sounds)) { + item { + SettingsChoice(it, stringResource(R.string.sleep_sound), SleepSound.entries, sound, { s -> stringResource(s.title) }, { s -> sound = s }, Icons.Outlined.Waves) + } + item { + SettingsChoice(it, stringResource(R.string.sleep_timer), listOf(15, 30, 45, 60, 90), minutes, { m -> stringResource(R.string.n_minutes, m) }, { m -> minutes = m }, Icons.Outlined.Bedtime) + } + item { + val on = playing != null + app.uhr.ui.components.SettingsItem( + it, stringResource(if (on) R.string.stop else R.string.sleep_play), + if (on) stringResource(R.string.sleep_ends, app.uhr.ui.components.Fmt.clock(playing!!.endsAt)) else null, + if (on) Icons.Rounded.Stop else Icons.Rounded.PlayArrow, + onClick = { if (on) SleepSoundService.stop(context) else SleepSoundService.start(context, sound, minutes) }, + ) + } + } +} diff --git a/uhr/app/src/main/java/app/uhr/ui/screens/ClockScreen.kt b/uhr/app/src/main/java/app/uhr/ui/screens/ClockScreen.kt index 6552617..9534106 100644 --- a/uhr/app/src/main/java/app/uhr/ui/screens/ClockScreen.kt +++ b/uhr/app/src/main/java/app/uhr/ui/screens/ClockScreen.kt @@ -40,6 +40,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.zIndex import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -69,6 +73,7 @@ fun ClockScreen( contentPadding: PaddingValues, onDeleteCity: (City) -> Unit, nowOverride: Long? = null, + onReorder: (List) -> Unit = {}, ) { val tick = remember { mutableLongStateOf(nowOverride ?: System.currentTimeMillis()) } if (nowOverride == null) LaunchedEffect(settings.showSeconds) { @@ -77,6 +82,11 @@ fun ClockScreen( val now = tick.longValue val res = LocalContext.current.resources val scheme = MaterialTheme.colorScheme + val view = androidx.compose.ui.platform.LocalView.current + val density = androidx.compose.ui.platform.LocalDensity.current + var order by remember(cities) { mutableStateOf(cities) } + var draggedId by remember { mutableStateOf(null) } + var dragOffset by remember { androidx.compose.runtime.mutableFloatStateOf(0f) } LazyColumn( Modifier.fillMaxSize(), contentPadding = PaddingValues(start = WSpace.s16, end = WSpace.s16, top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding() + 120.dp), @@ -101,7 +111,33 @@ fun ClockScreen( } } } - items(cities, key = { it.id }) { city -> CityRow(city, now, settings) { onDeleteCity(city) } } + items(order, key = { it.id }) { city -> + val dragging = draggedId == city.id + Box( + Modifier.zIndex(if (dragging) 1f else 0f) + .graphicsLayer { translationY = if (dragging) dragOffset else 0f; scaleX = if (dragging) 1.03f else 1f; scaleY = scaleX; shadowElevation = if (dragging) 12f else 0f } + .then(if (dragging) Modifier else Modifier.animateItem()) + .pointerInput(city.id) { + detectDragGesturesAfterLongPress( + onDragStart = { draggedId = city.id; dragOffset = 0f; view.performHapticFeedback(android.view.HapticFeedbackConstants.LONG_PRESS) }, + onDragEnd = { draggedId = null; dragOffset = 0f; onReorder(order.map { it.id }) }, + onDragCancel = { draggedId = null; dragOffset = 0f; order = cities }, + ) { change, drag -> + change.consume() + dragOffset += drag.y + // swap with the neighbour once the card has moved past half of it + val step = size.height + with(density) { WSpace.s8.toPx() } + val i = order.indexOfFirst { it.id == city.id } + val target = (i + (dragOffset / step).toInt()).coerceIn(0, order.lastIndex) + if (target != i) { + order = order.toMutableList().apply { add(target, removeAt(i)) } + dragOffset -= (target - i) * step + view.performHapticFeedback(android.view.HapticFeedbackConstants.SEGMENT_TICK) + } + } + }, + ) { CityRow(city, now, settings) { onDeleteCity(city) } } + } } } diff --git a/uhr/app/src/main/java/app/uhr/ui/screens/TimerScreen.kt b/uhr/app/src/main/java/app/uhr/ui/screens/TimerScreen.kt index db7de23..bb09f14 100644 --- a/uhr/app/src/main/java/app/uhr/ui/screens/TimerScreen.kt +++ b/uhr/app/src/main/java/app/uhr/ui/screens/TimerScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -72,6 +73,7 @@ interface TimerActions { fun reset(id: Long) fun delete(id: Long) fun stopRinging() + fun rename(id: Long, label: String) {} } /** S08: keypad when there is no timer (or after "+"), cards for running ones. */ @@ -109,7 +111,9 @@ private fun TimerCard(t: Timer, now: Long, actions: TimerActions) { val label = t.label.ifBlank { stringResource(R.string.timer_of, Fmt.duration(t.durationMs)) } Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow).padding(WSpace.s20)) { Row(verticalAlignment = Alignment.CenterVertically) { - Text(label, style = WType.title, modifier = Modifier.weight(1f)) + var renaming by remember { androidx.compose.runtime.mutableStateOf(false) } + Text(label, style = WType.title, modifier = Modifier.weight(1f).clip(RoundedCornerShape(WRadius.xs)).clickable { renaming = true }) + if (renaming) RenameDialog(t.label, { renaming = false }) { renaming = false; actions.rename(t.id, it) } IconButton(onClick = { actions.delete(t.id) }) { Icon(Icons.Rounded.Close, stringResource(R.string.delete)) } } Row(verticalAlignment = Alignment.CenterVertically) { @@ -205,3 +209,18 @@ private fun Keypad(digits: String, onDigits: (String) -> Unit, modifier: Modifie } } } + +@Composable +private fun RenameDialog(current: String, onDismiss: () -> Unit, onSave: (String) -> Unit) { + var text by remember { androidx.compose.runtime.mutableStateOf(current) } + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.rename_timer)) }, + text = { + androidx.compose.material3.OutlinedTextField(text, { text = it.take(40) }, singleLine = true, + placeholder = { Text(stringResource(R.string.add_label)) }, modifier = Modifier.fillMaxWidth()) + }, + confirmButton = { androidx.compose.material3.TextButton(onClick = { onSave(text.trim()) }) { Text(stringResource(android.R.string.ok)) } }, + dismissButton = { androidx.compose.material3.TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } }, + ) +} diff --git a/uhr/app/src/main/java/app/uhr/ui/theme/Tokens.kt b/uhr/app/src/main/java/app/uhr/ui/theme/Tokens.kt index efa007b..bea263f 100644 --- a/uhr/app/src/main/java/app/uhr/ui/theme/Tokens.kt +++ b/uhr/app/src/main/java/app/uhr/ui/theme/Tokens.kt @@ -65,6 +65,10 @@ object WColor { val timerProgress = Color(0xFF7EC4A8) val nightText = Color(0xFFC4433A) val nightBg = Color(0xFF000000) + val sunriseNight = Color(0xFF0D0710) + val sunriseEmber = Color(0xFF5A1A10) + val sunriseDay = Color(0xFFFFD9A0) + val sunriseSun = Color(0xFFFFF3D6) } object WType { diff --git a/uhr/app/src/main/res/raw/sleep_brown.ogg b/uhr/app/src/main/res/raw/sleep_brown.ogg new file mode 100644 index 0000000..4691a83 Binary files /dev/null and b/uhr/app/src/main/res/raw/sleep_brown.ogg differ diff --git a/uhr/app/src/main/res/raw/sleep_rain.ogg b/uhr/app/src/main/res/raw/sleep_rain.ogg new file mode 100644 index 0000000..3f3bbba Binary files /dev/null and b/uhr/app/src/main/res/raw/sleep_rain.ogg differ diff --git a/uhr/app/src/main/res/raw/sleep_waves.ogg b/uhr/app/src/main/res/raw/sleep_waves.ogg new file mode 100644 index 0000000..9706cff Binary files /dev/null and b/uhr/app/src/main/res/raw/sleep_waves.ogg differ diff --git a/uhr/app/src/main/res/values/strings.xml b/uhr/app/src/main/res/values/strings.xml index 471fe1e..ea05f54 100644 --- a/uhr/app/src/main/res/values/strings.xml +++ b/uhr/app/src/main/res/values/strings.xml @@ -87,6 +87,19 @@ Zur Schlafenszeit %1$d Min. vorher + Sonnenaufgang + 15 Min. vor dem Aufwachen wird der Bildschirm langsam heller + Sonnenaufgang läuft + Einschlafgeräusche + Geräusch + Ausschalten nach + Abspielen + Endet um %1$s, wird leiser + Läuft, blendet am Ende aus + Regen + Meeresrauschen + Tiefes Rauschen + Timer benennen Automatisch stumm nach Schlummerdauer Lautstärke