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.
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