diff --git a/replica/uhr/build-log.md b/replica/uhr/build-log.md
new file mode 100644
index 0000000..69ecce7
--- /dev/null
+++ b/replica/uhr/build-log.md
@@ -0,0 +1,26 @@
+# Build log: Uhr
+
+App in `uhr/`, same stack as `wetter/`. Screenshots: `./gradlew :app:recordRoborazziDebug` → `replica/uhr/clone-screens/` (fixed palette, not wallpaper colours).
+Device checks on a Pixel 10 Pro XL (Android 16) via a debug-only activity that saves a real alarm/timer through the repository.
+
+| ID | date | status | missing | harder than expected |
+| --- | --- | --- | --- | --- |
+| slice | 2026-10-17 | done | – | verified on device: exact alarm registered (RTC_WAKEUP, policy_permission), rang at 15:12:00 with alarm-stream audio, heads-up while unlocked, full-screen screen on tap, snooze moved it to +10 min, stop from the snoozed notification disabled the one-time alarm |
+| S01 Alarm list | 2026-10-17 | done | – | – |
+| S02 Expanded card | 2026-10-17 | done | – | label editing saves on dismiss |
+| 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 |
+| 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 |
+| S09 Stopwatch | 2026-10-17 | done | – | – |
+| S10 Bedtime | 2026-10-17 | partial | sunrise alarm, sleep sounds | – |
+| 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 | – |
+| S14 Screensaver | – | not started | | |
+| S15 Notifications | 2026-10-17 | done | – | upcoming, snoozed, missed, timers (live countdown), stopwatch (live) |
+| S16 Widgets | 2026-10-17 | partial | style options, world clock widget, picker previews; **not checked on the launcher** | live clocks via TextClock/AnalogClock RemoteViews |
+
+Not verified yet: alarm after reboot, after a time zone change, with battery saver, over the lock screen with the screen off (expected full screen there).
diff --git a/replica/uhr/clone-screens/S01-alarms-dark.png b/replica/uhr/clone-screens/S01-alarms-dark.png
new file mode 100644
index 0000000..c21574a
Binary files /dev/null and b/replica/uhr/clone-screens/S01-alarms-dark.png differ
diff --git a/replica/uhr/clone-screens/S01-alarms-light.png b/replica/uhr/clone-screens/S01-alarms-light.png
new file mode 100644
index 0000000..441b297
Binary files /dev/null and b/replica/uhr/clone-screens/S01-alarms-light.png differ
diff --git a/replica/uhr/clone-screens/S01-empty.png b/replica/uhr/clone-screens/S01-empty.png
new file mode 100644
index 0000000..6511ed6
Binary files /dev/null and b/replica/uhr/clone-screens/S01-empty.png differ
diff --git a/replica/uhr/clone-screens/S02-expanded-dark.png b/replica/uhr/clone-screens/S02-expanded-dark.png
new file mode 100644
index 0000000..2df4aac
Binary files /dev/null and b/replica/uhr/clone-screens/S02-expanded-dark.png differ
diff --git a/replica/uhr/clone-screens/S06-analog-light.png b/replica/uhr/clone-screens/S06-analog-light.png
new file mode 100644
index 0000000..bbb9679
Binary files /dev/null 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
new file mode 100644
index 0000000..0b79b7d
Binary files /dev/null and b/replica/uhr/clone-screens/S06-clock-dark.png differ
diff --git a/replica/uhr/clone-screens/S08-keypad-dark.png b/replica/uhr/clone-screens/S08-keypad-dark.png
new file mode 100644
index 0000000..f5815c8
Binary files /dev/null and b/replica/uhr/clone-screens/S08-keypad-dark.png differ
diff --git a/replica/uhr/clone-screens/S08-timers-dark.png b/replica/uhr/clone-screens/S08-timers-dark.png
new file mode 100644
index 0000000..496c460
Binary files /dev/null and b/replica/uhr/clone-screens/S08-timers-dark.png differ
diff --git a/replica/uhr/clone-screens/S09-stopwatch-dark.png b/replica/uhr/clone-screens/S09-stopwatch-dark.png
new file mode 100644
index 0000000..141eff4
Binary files /dev/null and b/replica/uhr/clone-screens/S09-stopwatch-dark.png differ
diff --git a/replica/uhr/clone-screens/S10-bedtime-dark.png b/replica/uhr/clone-screens/S10-bedtime-dark.png
new file mode 100644
index 0000000..da9c796
Binary files /dev/null and b/replica/uhr/clone-screens/S10-bedtime-dark.png differ
diff --git a/replica/uhr/clone-screens/S10-intro-light.png b/replica/uhr/clone-screens/S10-intro-light.png
new file mode 100644
index 0000000..a1cad76
Binary files /dev/null and b/replica/uhr/clone-screens/S10-intro-light.png differ
diff --git a/replica/uhr/clone-screens/S11-ringing.png b/replica/uhr/clone-screens/S11-ringing.png
new file mode 100644
index 0000000..03e5165
Binary files /dev/null and b/replica/uhr/clone-screens/S11-ringing.png differ
diff --git a/replica/uhr/clone-screens/S13-settings-dark.png b/replica/uhr/clone-screens/S13-settings-dark.png
new file mode 100644
index 0000000..a2bdd7f
Binary files /dev/null and b/replica/uhr/clone-screens/S13-settings-dark.png differ
diff --git a/replica/uhr/features.csv b/replica/uhr/features.csv
index dfa3574..3ba1921 100644
--- a/replica/uhr/features.csv
+++ b/replica/uhr/features.csv
@@ -1,57 +1,57 @@
-feature,area,priority,original,clone,notes
-Alarm list with on/off switch and next-ring text,alarm,must,yes,no,
-Add alarm with time picker (dial and keyboard),alarm,must,yes,no,
-Expandable alarm card (edit in place),alarm,must,yes,no,
-Repeat days chips,alarm,must,yes,no,
-Alarm label,alarm,must,yes,no,
-Delete alarm with undo,alarm,must,yes,no,
-Vibrate toggle,alarm,must,yes,no,
-Alarm sound picker with preview (system ringtones),alarm,must,yes,no,
-Own audio file as alarm sound,alarm,should,yes,no,
-Pause alarm until date,alarm,should,yes,no,
-Skip next occurrence (dismiss early),alarm,should,yes,no,from notification
-Exact scheduling that survives reboot time change and Doze,alarm,must,yes,no,AlarmManager.setAlarmClock
-Full-screen ringing screen over lock screen,ringing,must,yes,no,
-Slide control snooze/stop,ringing,must,yes,no,
-Snooze length setting,settings,must,yes,no,
-Silence after setting,settings,must,yes,no,
-Gradually increase volume,settings,should,yes,no,
-Alarm volume setting,settings,should,yes,no,
-Volume buttons behaviour (snooze/stop/volume/none),settings,should,yes,no,
-Start week on,settings,could,yes,no,
-Upcoming alarm notification with dismiss now,notifications,should,yes,no,
-Missed alarm notification,notifications,should,yes,no,
-Snoozed notification with dismiss,notifications,must,yes,no,
-World clock: local time and date,clock,must,yes,no,
-World clock: add city via search,clock,must,yes,no,
-World clock: reorder and swipe to delete,clock,should,yes,no,
-Analog or digital clock style,clock,should,yes,no,
-Show seconds,clock,could,yes,no,
-Automatic home clock when travelling,clock,could,yes,no,
-Timer keypad h m s,timer,must,yes,no,
-Run several timers at once,timer,must,yes,no,
-Timer ring progress +1:00 pause reset delete,timer,must,yes,no,
-Timer labels,timer,could,yes,no,
-Timer runs in background with notification,timer,must,yes,no,
-Timer ringing screen,timer,must,yes,no,
-Timer sound and vibrate settings,settings,should,yes,no,
-Stopwatch start stop reset,stopwatch,must,yes,no,
-Stopwatch laps,stopwatch,must,yes,no,
-Stopwatch running notification,stopwatch,should,yes,no,
-Bedtime schedule with wake-up alarm,bedtime,should,yes,no,
-Bedtime reminder notification,bedtime,should,yes,no,
-Sunrise alarm (brightening screen),bedtime,could,yes,no,
-Sleep sounds with timer,bedtime,could,yes,no,own CC0 sounds
-Screensaver / bedside clock with night mode,display,could,yes,no,DreamService
-Digital clock widget,widgets,must,yes,no,
-Analog clock widget,widgets,should,yes,no,
-World clock widget,widgets,could,yes,no,
-Next alarm widget,widgets,should,yes,no,
-Widget style options (theme colours opacity),widgets,should,yes,no,like Wetter
-Theme system/light/dark and dynamic colour,display,must,yes,no,
-Expressive design (bottom bar pill cards shapes),display,must,yes,no,
-Streaming alarm sounds (YouTube Music Spotify Calm),alarm,could,yes,skip,partner accounts
-Google Assistant Routine,alarm,could,yes,skip,Google service
-Bedtime mode greyscale and DND,bedtime,could,yes,skip,Digital Wellbeing privileges
-Sleep tracking cough and snore,bedtime,could,yes,skip,system privileges + Pixel hardware
-Calendar upcoming events card,bedtime,could,yes,skip,Google Calendar
+feature,area,priority,original,clone,notes
+Alarm list with on/off switch and next-ring text,alarm,must,yes,yes,
+Add alarm with time picker (dial and keyboard),alarm,must,yes,yes,
+Expandable alarm card (edit in place),alarm,must,yes,yes,
+Repeat days chips,alarm,must,yes,yes,
+Alarm label,alarm,must,yes,yes,
+Delete alarm with undo,alarm,must,yes,yes,
+Vibrate toggle,alarm,must,yes,yes,
+Alarm sound picker with preview (system ringtones),alarm,must,yes,yes,
+Own audio file as alarm sound,alarm,should,yes,partial,picker works; file loss falls back to default; not device-tested
+Pause alarm until date,alarm,should,yes,yes,
+Skip next occurrence (dismiss early),alarm,should,yes,yes,from notification
+Exact scheduling that survives reboot time change and Doze,alarm,must,yes,yes,AlarmManager.setAlarmClock
+Full-screen ringing screen over lock screen,ringing,must,yes,yes,
+Slide control snooze/stop,ringing,must,yes,yes,
+Snooze length setting,settings,must,yes,yes,
+Silence after setting,settings,must,yes,yes,
+Gradually increase volume,settings,should,yes,yes,
+Alarm volume setting,settings,should,yes,yes,
+Volume buttons behaviour (snooze/stop/volume/none),settings,should,yes,yes,
+Start week on,settings,could,yes,yes,
+Upcoming alarm notification with dismiss now,notifications,should,yes,yes,
+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
+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 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
+Stopwatch start stop reset,stopwatch,must,yes,yes,
+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
+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,
+World clock widget,widgets,could,yes,no,
+Next alarm widget,widgets,should,yes,yes,
+Widget style options (theme colours opacity),widgets,should,yes,no,like Wetter
+Theme system/light/dark and dynamic colour,display,must,yes,yes,
+Expressive design (bottom bar pill cards shapes),display,must,yes,yes,
+Streaming alarm sounds (YouTube Music Spotify Calm),alarm,could,yes,skip,partner accounts
+Google Assistant Routine,alarm,could,yes,skip,Google service
+Bedtime mode greyscale and DND,bedtime,could,yes,skip,Digital Wellbeing privileges
+Sleep tracking cough and snore,bedtime,could,yes,skip,system privileges + Pixel hardware
+Calendar upcoming events card,bedtime,could,yes,skip,Google Calendar
diff --git a/uhr/.gitignore b/uhr/.gitignore
new file mode 100644
index 0000000..77d60bd
--- /dev/null
+++ b/uhr/.gitignore
@@ -0,0 +1,6 @@
+.gradle/
+build/
+local.properties
+.idea/
+*.iml
+.kotlin/
diff --git a/uhr/app/build.gradle.kts b/uhr/app/build.gradle.kts
new file mode 100644
index 0000000..0c2bc29
--- /dev/null
+++ b/uhr/app/build.gradle.kts
@@ -0,0 +1,97 @@
+import java.util.Properties
+
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
+ id("com.google.devtools.ksp")
+ id("io.github.takahirom.roborazzi")
+}
+
+android {
+ namespace = "app.uhr"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "app.uhr"
+ minSdk = 29
+ targetSdk = 36
+ versionCode = 1
+ versionName = "0.1.0"
+ }
+
+ // Release key lives outside the repo; without it, release builds fall back to the debug key (local only).
+ val keyProps = Properties().apply {
+ val f = file(System.getProperty("user.home") + "/.android/nogoogleapps-release.properties")
+ if (f.exists()) f.inputStream().use { load(it) }
+ }
+ signingConfigs {
+ if (keyProps.isNotEmpty()) create("release") {
+ storeFile = file(keyProps.getProperty("storeFile"))
+ storePassword = keyProps.getProperty("storePassword")
+ keyAlias = keyProps.getProperty("keyAlias")
+ keyPassword = keyProps.getProperty("keyPassword")
+ }
+ }
+
+ buildTypes {
+ debug {
+ // installs next to the release app from Obtainium
+ applicationIdSuffix = ".debug"
+ resValue("string", "app_name", "Uhr (Debug)")
+ }
+ release {
+ resValue("string", "app_name", "Uhr")
+ isMinifyEnabled = true
+ isShrinkResources = true
+ signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ buildFeatures { compose = true }
+ testOptions { unitTests { isIncludeAndroidResources = true } }
+}
+
+kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } }
+
+ksp { arg("room.schemaLocation", "$projectDir/schemas") }
+
+dependencies {
+ val composeBom = platform("androidx.compose:compose-bom:2025.10.01")
+ implementation(composeBom)
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.foundation:foundation")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.material:material-icons-extended")
+ debugImplementation("androidx.compose.ui:ui-tooling")
+
+ implementation("androidx.activity:activity-compose:1.11.0")
+ implementation("androidx.core:core-ktx:1.17.0")
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.4")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4")
+ implementation("androidx.graphics:graphics-shapes:1.0.1")
+
+ implementation("androidx.room:room-runtime:2.7.0")
+ implementation("androidx.room:room-ktx:2.7.0")
+ ksp("androidx.room:room-compiler:2.7.0")
+ implementation("androidx.datastore:datastore-preferences:1.1.7")
+ implementation("androidx.work:work-runtime-ktx:2.11.0")
+ implementation("androidx.glance:glance-appwidget:1.2.0")
+ implementation("androidx.glance:glance-material3:1.2.0")
+
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
+
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("org.robolectric:robolectric:4.15.1")
+ testImplementation("androidx.compose.ui:ui-test-junit4")
+ debugImplementation("androidx.compose.ui:ui-test-manifest")
+ testImplementation("io.github.takahirom.roborazzi:roborazzi:1.46.1")
+ testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.46.1")
+}
+
+roborazzi { outputDir.set(rootProject.file("../replica/uhr/clone-screens")) }
diff --git a/uhr/app/proguard-rules.pro b/uhr/app/proguard-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/uhr/app/schemas/app.uhr.data.ClockDb/1.json b/uhr/app/schemas/app.uhr.data.ClockDb/1.json
new file mode 100644
index 0000000..d2e1b2e
--- /dev/null
+++ b/uhr/app/schemas/app.uhr.data.ClockDb/1.json
@@ -0,0 +1,289 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 1,
+ "identityHash": "577de12663ea3a99bc9d48c8ed391a91",
+ "entities": [
+ {
+ "tableName": "alarms",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `days` INTEGER NOT NULL, `label` TEXT NOT NULL, `ringtone_uri` TEXT, `vibrate` INTEGER NOT NULL, `skip_until` TEXT, `is_bedtime_wake` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hour",
+ "columnName": "hour",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "minute",
+ "columnName": "minute",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "enabled",
+ "columnName": "enabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "days",
+ "columnName": "days",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "label",
+ "columnName": "label",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ringtoneUri",
+ "columnName": "ringtone_uri",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "vibrate",
+ "columnName": "vibrate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "skipUntil",
+ "columnName": "skip_until",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "isBedtimeWake",
+ "columnName": "is_bedtime_wake",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_alarms_hour_minute",
+ "unique": false,
+ "columnNames": [
+ "hour",
+ "minute"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_alarms_hour_minute` ON `${TABLE_NAME}` (`hour`, `minute`)"
+ }
+ ]
+ },
+ {
+ "tableName": "alarm_instances",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`alarm_id` INTEGER NOT NULL, `fire_at` INTEGER NOT NULL, `state` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`alarm_id`), FOREIGN KEY(`alarm_id`) REFERENCES `alarms`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "alarmId",
+ "columnName": "alarm_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fireAt",
+ "columnName": "fire_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "state",
+ "columnName": "state",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "alarm_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_alarm_instances_state_fire_at",
+ "unique": false,
+ "columnNames": [
+ "state",
+ "fire_at"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_alarm_instances_state_fire_at` ON `${TABLE_NAME}` (`state`, `fire_at`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "alarms",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "alarm_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "timers",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `label` TEXT NOT NULL, `duration_ms` INTEGER NOT NULL, `remaining_ms` INTEGER NOT NULL, `end_at` INTEGER, `state` TEXT NOT NULL, `created_at` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "label",
+ "columnName": "label",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "durationMs",
+ "columnName": "duration_ms",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "remainingMs",
+ "columnName": "remaining_ms",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "endAt",
+ "columnName": "end_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "state",
+ "columnName": "state",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_timers_state_end_at",
+ "unique": false,
+ "columnNames": [
+ "state",
+ "end_at"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_timers_state_end_at` ON `${TABLE_NAME}` (`state`, `end_at`)"
+ }
+ ]
+ },
+ {
+ "tableName": "cities",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `zone_id` TEXT NOT NULL, `name` TEXT NOT NULL, `sort_order` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "zoneId",
+ "columnName": "zone_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortOrder",
+ "columnName": "sort_order",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_cities_zone_id",
+ "unique": true,
+ "columnNames": [
+ "zone_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cities_zone_id` ON `${TABLE_NAME}` (`zone_id`)"
+ },
+ {
+ "name": "index_cities_sort_order",
+ "unique": false,
+ "columnNames": [
+ "sort_order"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_cities_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
+ }
+ ]
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '577de12663ea3a99bc9d48c8ed391a91')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/uhr/app/src/debug/AndroidManifest.xml b/uhr/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..2478c0a
--- /dev/null
+++ b/uhr/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/uhr/app/src/debug/java/app/uhr/DebugAlarmActivity.kt b/uhr/app/src/debug/java/app/uhr/DebugAlarmActivity.kt
new file mode 100644
index 0000000..5164632
--- /dev/null
+++ b/uhr/app/src/debug/java/app/uhr/DebugAlarmActivity.kt
@@ -0,0 +1,27 @@
+package app.uhr
+
+import android.app.Activity
+import android.os.Bundle
+import android.util.Log
+import app.uhr.data.Alarm
+import app.uhr.data.Graph
+import kotlinx.coroutines.runBlocking
+import java.time.LocalTime
+
+/** Debug only: `--ei in_seconds 70` saves a real alarm that far ahead, through the normal repository path. */
+class DebugAlarmActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ Graph.init(this)
+ val timerS = intent.getIntExtra("timer_seconds", 0)
+ if (timerS > 0) {
+ val id = runBlocking { Graph.repo.startTimer(timerS * 1000L, "Testtimer") }
+ Log.i("DebugAlarm", "timer $id for ${timerS}s"); finish(); return
+ }
+ val t = LocalTime.now().plusSeconds(intent.getIntExtra("in_seconds", 70).toLong())
+ val id = runBlocking { Graph.repo.save(Alarm(hour = t.hour, minute = t.minute, label = intent.getStringExtra("label") ?: "Test")) }
+ val inst = runBlocking { Graph.repo.instance(id) }
+ Log.i("DebugAlarm", "alarm $id at ${t.hour}:${t.minute} fires ${inst?.fireAt}")
+ finish()
+ }
+}
diff --git a/uhr/app/src/main/AndroidManifest.xml b/uhr/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a21028c
--- /dev/null
+++ b/uhr/app/src/main/AndroidManifest.xml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/uhr/app/src/main/java/app/uhr/MainActivity.kt b/uhr/app/src/main/java/app/uhr/MainActivity.kt
new file mode 100644
index 0000000..815137b
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/MainActivity.kt
@@ -0,0 +1,260 @@
+package app.uhr
+
+import android.Manifest
+import android.app.NotificationManager
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.provider.Settings as SystemSettings
+import android.text.format.DateFormat
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Alarm
+import androidx.compose.material.icons.outlined.Bedtime
+import androidx.compose.material.icons.outlined.HourglassEmpty
+import androidx.compose.material.icons.outlined.Schedule
+import androidx.compose.material.icons.outlined.Timer
+import androidx.compose.material.icons.rounded.Add
+import androidx.compose.material.icons.rounded.MoreVert
+import androidx.compose.material.icons.rounded.PlayArrow
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import app.uhr.alarm.RingingService
+import app.uhr.data.Alarm
+import app.uhr.data.Graph
+import app.uhr.data.Settings
+import app.uhr.ui.components.Fmt
+import app.uhr.ui.screens.AddCityDialog
+import app.uhr.ui.screens.AlarmActions
+import app.uhr.ui.screens.AlarmScreen
+import app.uhr.ui.screens.BedtimeScreen
+import app.uhr.ui.screens.ClockScreen
+import app.uhr.ui.screens.SettingsScreen
+import app.uhr.ui.screens.StopwatchScreen
+import app.uhr.ui.screens.TimeDialog
+import app.uhr.ui.screens.TimerActions
+import app.uhr.ui.screens.TimerScreen
+import app.uhr.ui.screens.digitsToMs
+import app.uhr.ui.theme.ClockTheme
+import kotlinx.coroutines.launch
+import java.time.LocalTime
+
+class MainActivity : ComponentActivity() {
+ private var tabFromIntent = mutableIntStateOf(-1)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ Graph.init(this)
+ Fmt.is24h = DateFormat.is24HourFormat(this)
+ tabFromIntent.intValue = intent.getIntExtra(EXTRA_TAB, -1)
+ setContent {
+ val settings by Graph.repo.prefs.settings.collectAsState(initial = Settings())
+ ClockTheme(settings.theme) { ClockApp(settings, tabFromIntent.intValue) }
+ }
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ tabFromIntent.intValue = intent.getIntExtra(EXTRA_TAB, -1)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ Fmt.is24h = DateFormat.is24HourFormat(this)
+ }
+
+ companion object { const val EXTRA_TAB = "tab" }
+}
+
+private data class Tab(val title: Int, val icon: ImageVector)
+
+private val TABS = listOf(
+ Tab(R.string.tab_alarm, Icons.Outlined.Alarm),
+ Tab(R.string.tab_clock, Icons.Outlined.Schedule),
+ Tab(R.string.tab_timer, Icons.Outlined.HourglassEmpty),
+ Tab(R.string.tab_stopwatch, Icons.Outlined.Timer),
+ Tab(R.string.tab_bedtime, Icons.Outlined.Bedtime),
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ClockApp(settings: Settings, requestedTab: Int) {
+ val repo = Graph.repo
+ val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+ var tab by rememberSaveable { mutableIntStateOf(0) }
+ LaunchedEffect(requestedTab) { if (requestedTab in TABS.indices) tab = requestedTab }
+ var showSettings by rememberSaveable { mutableStateOf(false) }
+ var menu by remember { mutableStateOf(false) }
+ var addAlarm by remember { mutableStateOf(false) }
+ var addCity by remember { mutableStateOf(false) }
+ var keypad by rememberSaveable { mutableStateOf(false) }
+ var digits by rememberSaveable { mutableStateOf("") }
+ val snackbar = remember { SnackbarHostState() }
+
+ val alarms by repo.alarms.collectAsState(initial = emptyList())
+ val timers by repo.timers.collectAsState(initial = emptyList())
+ val cities by repo.cities.collectAsState(initial = emptyList())
+ val next by repo.nextInstance.collectAsState(initial = null)
+ val sw by repo.stopwatch.collectAsState(initial = app.uhr.data.Stopwatch())
+ val bedtime by repo.bedtime.collectAsState(initial = app.uhr.data.Bedtime())
+
+ PermissionGate()
+
+ if (showSettings) {
+ BackHandler { showSettings = false }
+ SettingsScreen(settings, { t -> scope.launch { repo.prefs.update(t) } }, onBack = { showSettings = false })
+ return
+ }
+
+ val alarmActions = remember {
+ object : AlarmActions {
+ override fun save(alarm: Alarm) { scope.launch { repo.save(alarm) } }
+ override fun delete(alarm: Alarm) { scope.launch { repo.delete(alarm) } }
+ override fun restore(alarm: Alarm) { scope.launch { repo.restore(alarm) } }
+ override fun dismissNext(alarm: Alarm) { scope.launch { repo.dismissNext(alarm.id) } }
+ }
+ }
+ val timerActions = remember {
+ object : TimerActions {
+ override fun start(durationMs: Long) { scope.launch { repo.startTimer(durationMs) } }
+ override fun pause(id: Long) { scope.launch { repo.pauseTimer(id) } }
+ override fun resume(id: Long) { scope.launch { repo.resumeTimer(id) } }
+ override fun addMinute(id: Long) { scope.launch { repo.addMinute(id) } }
+ 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) }
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(TABS[tab].title)) },
+ actions = {
+ IconButton(onClick = { menu = true }) { Icon(Icons.Rounded.MoreVert, stringResource(R.string.more)) }
+ DropdownMenu(menu, { menu = false }) {
+ DropdownMenuItem(text = { Text(stringResource(R.string.settings)) }, onClick = { menu = false; showSettings = true })
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface),
+ )
+ },
+ bottomBar = {
+ NavigationBar {
+ TABS.forEachIndexed { i, t ->
+ NavigationBarItem(selected = tab == i, onClick = { tab = i }, icon = { Icon(t.icon, null) }, label = { Text(stringResource(t.title)) })
+ }
+ }
+ },
+ floatingActionButton = {
+ val fab: (() -> Unit)? = when (tab) {
+ 0 -> ({ addAlarm = true })
+ 1 -> ({ addCity = true })
+ 2 -> if (timers.isEmpty() || keypad) {
+ if (digits.isNotEmpty()) ({ timerActions.start(digitsToMs(digits)); digits = ""; keypad = false }) else null
+ } else ({ keypad = true })
+ else -> null
+ }
+ if (fab != null) {
+ val icon = if (tab == 2 && (timers.isEmpty() || keypad)) Icons.Rounded.PlayArrow else Icons.Rounded.Add
+ FloatingActionButton(onClick = fab, shape = CircleShape, modifier = Modifier.size(96.dp),
+ containerColor = app.uhr.ui.theme.fabColors().first, contentColor = app.uhr.ui.theme.fabColors().second) {
+ Icon(icon, stringResource(R.string.add), Modifier.size(36.dp))
+ }
+ }
+ },
+ floatingActionButtonPosition = androidx.compose.material3.FabPosition.Center,
+ snackbarHost = { SnackbarHost(snackbar) },
+ ) { pad ->
+ when (tab) {
+ 0 -> AlarmScreen(alarms, settings, alarmActions, snackbar, pad)
+ 1 -> ClockScreen(settings, cities, next, pad, onDeleteCity = { c -> scope.launch { repo.deleteCity(c.id) } })
+ 2 -> {
+ if (keypad && timers.isNotEmpty()) BackHandler { keypad = false; digits = "" }
+ TimerScreen(timers, keypad, digits, { digits = it }, timerActions, pad)
+ }
+ 3 -> StopwatchScreen(sw, { scope.launch { repo.stopwatchToggle() } }, { scope.launch { repo.stopwatchLap() } }, { scope.launch { repo.stopwatchReset() } }, pad)
+ 4 -> BedtimeScreen(bedtime, settings, { b -> scope.launch { repo.saveBedtime(b) } }, pad)
+ }
+ }
+
+ if (addAlarm) {
+ val t = LocalTime.now().plusHours(1).withMinute(0)
+ TimeDialog(t.hour, t.minute, { addAlarm = false }) { h, m ->
+ addAlarm = false
+ scope.launch {
+ val id = repo.save(Alarm(hour = h, minute = m))
+ repo.instance(id)?.let { snackbar.showSnackbar(context.getString(R.string.alarm_set_in, Fmt.until(context.resources, it.fireAt))) }
+ }
+ }
+ }
+ if (addCity) AddCityDialog({ addCity = false }) { hit -> addCity = false; scope.launch { repo.addCity(hit) } }
+}
+
+/** Notifications and full-screen ringing are what make an alarm app work: ask once, explain if missing. */
+@Composable
+private fun PermissionGate() {
+ val context = LocalContext.current
+ var asked by rememberSaveable { mutableStateOf(false) }
+ val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { }
+ LaunchedEffect(Unit) {
+ if (!asked && Build.VERSION.SDK_INT >= 33) { asked = true; launcher.launch(Manifest.permission.POST_NOTIFICATIONS) }
+ }
+ val nm = context.getSystemService(NotificationManager::class.java)
+ var hide by rememberSaveable { mutableStateOf(false) }
+ if (Build.VERSION.SDK_INT >= 34 && !nm.canUseFullScreenIntent() && !hide) {
+ androidx.compose.material3.AlertDialog(
+ onDismissRequest = { hide = true },
+ title = { Text(stringResource(R.string.fsi_title)) },
+ text = { Text(stringResource(R.string.fsi_text)) },
+ confirmButton = {
+ TextButton(onClick = {
+ hide = true
+ context.startActivity(Intent(SystemSettings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, android.net.Uri.parse("package:${context.packageName}")))
+ }) { Text(stringResource(R.string.open_settings)) }
+ },
+ dismissButton = { TextButton(onClick = { hide = true }) { Text(stringResource(R.string.later)) } },
+ )
+ }
+}
diff --git a/uhr/app/src/main/java/app/uhr/UhrApp.kt b/uhr/app/src/main/java/app/uhr/UhrApp.kt
new file mode 100644
index 0000000..cc29c1a
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/UhrApp.kt
@@ -0,0 +1,13 @@
+package app.uhr
+
+import android.app.Application
+import app.uhr.alarm.Notifications
+import app.uhr.data.Graph
+
+class UhrApp : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ Graph.init(this)
+ Notifications.createChannels(this)
+ }
+}
diff --git a/uhr/app/src/main/java/app/uhr/alarm/AlarmReceiver.kt b/uhr/app/src/main/java/app/uhr/alarm/AlarmReceiver.kt
new file mode 100644
index 0000000..4a43724
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/alarm/AlarmReceiver.kt
@@ -0,0 +1,56 @@
+package app.uhr.alarm
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import app.uhr.data.Graph
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+
+/** Entry point for every scheduled event and every notification action. */
+class AlarmReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ Graph.init(context)
+ val id = intent.getLongExtra(EXTRA_ID, -1)
+ when (intent.action) {
+ // ringing must start while the receiver is alive: foreground service right away
+ ACTION_RING -> RingingService.startAlarm(context, id)
+ ACTION_TIMER -> RingingService.startTimer(context, id)
+ else -> {
+ val pending = goAsync()
+ scope.launch {
+ try { handle(context, intent.action, id) } finally { pending.finish() }
+ }
+ }
+ }
+ }
+
+ private suspend fun handle(context: Context, action: String?, id: Long) {
+ 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()
+ 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_BEDTIME -> { Notifications.bedtime(context); repo.scheduleBedtime() }
+ }
+ }
+
+ companion object {
+ const val EXTRA_ID = "id"
+ const val ACTION_RING = "app.uhr.RING"
+ const val ACTION_UPCOMING = "app.uhr.UPCOMING"
+ const val ACTION_DISMISS_NEXT = "app.uhr.DISMISS_NEXT"
+ const val ACTION_SNOOZE_DISMISS = "app.uhr.SNOOZE_DISMISS"
+ const val ACTION_TIMER = "app.uhr.TIMER"
+ 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"
+ 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
new file mode 100644
index 0000000..7bdbe27
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/alarm/Notifications.kt
@@ -0,0 +1,158 @@
+package app.uhr.alarm
+
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.SystemClock
+import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
+import app.uhr.MainActivity
+import app.uhr.R
+import app.uhr.data.Alarm
+import app.uhr.data.Stopwatch
+import app.uhr.data.StopwatchState
+import app.uhr.data.Timer
+import app.uhr.data.TimerState
+import java.text.DateFormat
+import java.util.Date
+
+object Notifications {
+ const val CH_RINGING = "ringing"
+ const val CH_UPCOMING = "upcoming"
+ const val CH_MISSED = "missed"
+ const val CH_RUNNING = "running"
+ const val CH_BEDTIME = "bedtime"
+
+ const val ID_TIMERS = 1
+ const val ID_STOPWATCH = 2
+ const val ID_RINGING = 3
+ const val ID_BEDTIME = 4
+ private const val ALARM_BASE = 1000
+
+ fun createChannels(context: Context) {
+ val nm = context.getSystemService(NotificationManager::class.java)
+ nm.createNotificationChannels(
+ listOf(
+ NotificationChannel(CH_RINGING, context.getString(R.string.ch_ringing), NotificationManager.IMPORTANCE_HIGH).apply {
+ setSound(null, null); enableVibration(false); setBypassDnd(true)
+ },
+ NotificationChannel(CH_UPCOMING, context.getString(R.string.ch_upcoming), NotificationManager.IMPORTANCE_LOW),
+ NotificationChannel(CH_MISSED, context.getString(R.string.ch_missed), NotificationManager.IMPORTANCE_DEFAULT),
+ NotificationChannel(CH_RUNNING, context.getString(R.string.ch_running), NotificationManager.IMPORTANCE_LOW),
+ NotificationChannel(CH_BEDTIME, context.getString(R.string.ch_bedtime), NotificationManager.IMPORTANCE_DEFAULT),
+ )
+ )
+ }
+
+ private fun allowed(context: Context) =
+ ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
+
+ private fun nm(context: Context) = context.getSystemService(NotificationManager::class.java)
+
+ private fun open(context: Context, tab: Int = 0) = PendingIntent.getActivity(
+ context, 100 + tab, Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_TAB, tab),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+
+ private fun action(context: Context, action: String, id: Long) = PendingIntent.getBroadcast(
+ context, (action.hashCode() * 31 + id).toInt(),
+ Intent(context, AlarmReceiver::class.java).setAction(action).putExtra(AlarmReceiver.EXTRA_ID, id),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+
+ private fun time(at: Long) = DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(at))
+ private fun title(context: Context, a: Alarm) = a.label.ifBlank { context.getString(R.string.alarm) }
+
+ fun upcoming(context: Context, a: Alarm, fireAt: Long) {
+ if (!allowed(context)) return
+ nm(context).notify(
+ (ALARM_BASE + a.id).toInt(),
+ NotificationCompat.Builder(context, CH_UPCOMING).setSmallIcon(R.drawable.ic_stat_alarm)
+ .setContentTitle(context.getString(R.string.notif_upcoming, time(fireAt)))
+ .setContentText(title(context, a)).setContentIntent(open(context))
+ .addAction(0, context.getString(R.string.dismiss_now), action(context, AlarmReceiver.ACTION_DISMISS_NEXT, a.id))
+ .setCategory(NotificationCompat.CATEGORY_REMINDER).build(),
+ )
+ }
+
+ fun snoozed(context: Context, a: Alarm, until: Long) {
+ if (!allowed(context)) return
+ nm(context).notify(
+ (ALARM_BASE + a.id).toInt(),
+ NotificationCompat.Builder(context, CH_UPCOMING).setSmallIcon(R.drawable.ic_stat_alarm)
+ .setContentTitle(context.getString(R.string.notif_snoozed, time(until)))
+ .setContentText(title(context, a)).setContentIntent(open(context)).setOngoing(true)
+ .addAction(0, context.getString(R.string.stop), action(context, AlarmReceiver.ACTION_SNOOZE_DISMISS, a.id))
+ .build(),
+ )
+ }
+
+ fun missed(context: Context, a: Alarm) {
+ if (!allowed(context)) return
+ nm(context).notify(
+ (ALARM_BASE + a.id).toInt(),
+ NotificationCompat.Builder(context, CH_MISSED).setSmallIcon(R.drawable.ic_stat_alarm)
+ .setContentTitle(context.getString(R.string.notif_missed))
+ .setContentText("${title(context, a)} · ${String.format("%d:%02d", a.hour, a.minute)}")
+ .setContentIntent(open(context)).setAutoCancel(true).build(),
+ )
+ }
+
+ fun cancelAlarm(context: Context, id: Long) = nm(context).cancel((ALARM_BASE + id).toInt())
+
+ /** One notification for all running/paused timers; the soonest one counts down live. */
+ fun timers(context: Context, timers: List) {
+ val active = timers.filter { it.state != TimerState.RINGING }
+ if (active.isEmpty() || !allowed(context)) { nm(context).cancel(ID_TIMERS); return }
+ val next = active.filter { it.state == TimerState.RUNNING }.minByOrNull { it.endAt ?: Long.MAX_VALUE } ?: active.first()
+ val b = NotificationCompat.Builder(context, CH_RUNNING).setSmallIcon(R.drawable.ic_stat_timer)
+ .setContentTitle(next.label.ifBlank { context.getString(R.string.timer) })
+ .setContentIntent(open(context, 2)).setOngoing(true).setOnlyAlertOnce(true).setSilent(true)
+ .setCategory(NotificationCompat.CATEGORY_STOPWATCH)
+ if (next.state == TimerState.RUNNING) {
+ b.setUsesChronometer(true).setChronometerCountDown(true).setWhen(next.endAt ?: 0).setShowWhen(true)
+ b.addAction(0, context.getString(R.string.pause), action(context, AlarmReceiver.ACTION_TIMER_PAUSE, next.id))
+ } else {
+ b.setContentText(context.getString(R.string.paused))
+ }
+ b.addAction(0, context.getString(R.string.plus_minute), action(context, AlarmReceiver.ACTION_TIMER_ADD_MINUTE, next.id))
+ if (active.size > 1) b.setSubText(context.getString(R.string.n_timers, active.size))
+ nm(context).notify(ID_TIMERS, b.build())
+ }
+
+ fun stopwatch(context: Context, s: Stopwatch) {
+ if (s.state != StopwatchState.RUNNING || !allowed(context)) { nm(context).cancel(ID_STOPWATCH); return }
+ // chronometer counts from a wall-clock base: translate the elapsedRealtime start
+ val base = System.currentTimeMillis() - (SystemClock.elapsedRealtime() - s.startedAt) - s.accumulatedMs
+ nm(context).notify(
+ ID_STOPWATCH,
+ NotificationCompat.Builder(context, CH_RUNNING).setSmallIcon(R.drawable.ic_stat_timer)
+ .setContentTitle(context.getString(R.string.stopwatch)).setUsesChronometer(true).setWhen(base).setShowWhen(true)
+ .setContentIntent(open(context, 3)).setOngoing(true).setSilent(true).build(),
+ )
+ }
+
+ fun bedtime(context: Context) {
+ if (!allowed(context)) return
+ nm(context).notify(
+ ID_BEDTIME,
+ NotificationCompat.Builder(context, CH_BEDTIME).setSmallIcon(R.drawable.ic_stat_bedtime)
+ .setContentTitle(context.getString(R.string.notif_bedtime)).setContentText(context.getString(R.string.notif_bedtime_text))
+ .setContentIntent(open(context, 4)).setAutoCancel(true).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)
+ .setCategory(NotificationCompat.CATEGORY_ALARM).setPriority(NotificationCompat.PRIORITY_MAX)
+ .setFullScreenIntent(fullScreen, true).setContentIntent(fullScreen).setOngoing(true)
+ .apply { if (snooze != null) addAction(0, context.getString(R.string.snooze), snooze) }
+ .addAction(0, context.getString(R.string.stop), stop)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC).build()
+}
diff --git a/uhr/app/src/main/java/app/uhr/alarm/RingingActivity.kt b/uhr/app/src/main/java/app/uhr/alarm/RingingActivity.kt
new file mode 100644
index 0000000..6a6b860
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/alarm/RingingActivity.kt
@@ -0,0 +1,169 @@
+package app.uhr.alarm
+
+import android.os.Bundle
+import android.view.KeyEvent
+import android.view.WindowManager
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectHorizontalDragGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+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.rounded.Alarm
+import androidx.compose.material.icons.rounded.HourglassBottom
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.CustomAccessibilityAction
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.customActions
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import app.uhr.R
+import app.uhr.data.Graph
+import app.uhr.data.VolumeButtons
+import app.uhr.ui.components.Fmt
+import app.uhr.ui.theme.WType
+import app.uhr.ui.theme.WColor
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlin.math.abs
+import kotlin.math.roundToInt
+
+/** Full-screen ringing UI, shown over the lock screen. */
+class RingingActivity : ComponentActivity() {
+ private var volumeButtons = VolumeButtons.VOLUME
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setShowWhenLocked(true)
+ setTurnScreenOn(true)
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ enableEdgeToEdge()
+ Graph.init(this)
+ setContent {
+ val ring by RingingService.ringing.collectAsState()
+ LaunchedEffect(Unit) { volumeButtons = Graph.repo.prefs.settingsOnce().volumeButtons }
+ LaunchedEffect(ring) { if (ring == null) { delay(300); if (RingingService.ringing.value == null) finish() } }
+ ring?.let { RingingScreen(it, onSnooze = { RingingService.snooze(this) }, onStop = { RingingService.stop(this) }) }
+ }
+ }
+
+ override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
+ if (keyCode != KeyEvent.KEYCODE_VOLUME_UP && keyCode != KeyEvent.KEYCODE_VOLUME_DOWN) return super.onKeyDown(keyCode, event)
+ when (volumeButtons) {
+ VolumeButtons.SNOOZE -> if (RingingService.ringing.value is Ring.AlarmRing) RingingService.snooze(this) else RingingService.stop(this)
+ VolumeButtons.STOP -> RingingService.stop(this)
+ VolumeButtons.NOTHING -> Unit
+ VolumeButtons.VOLUME -> return super.onKeyDown(keyCode, event)
+ }
+ return true
+ }
+}
+
+@Composable
+fun RingingScreen(ring: Ring, onSnooze: () -> Unit, onStop: () -> Unit) {
+ val now = remember { mutableLongStateOf(System.currentTimeMillis()) }
+ LaunchedEffect(Unit) { while (true) { now.longValue = System.currentTimeMillis(); delay(1000) } }
+ val label = when (ring) {
+ is Ring.AlarmRing -> ring.alarm.label.ifBlank { stringResource(R.string.alarm) }
+ is Ring.TimerRing -> ring.label.ifBlank { stringResource(R.string.timer_done) }
+ }
+ Column(
+ Modifier.fillMaxSize().background(WColor.ringBg).navigationBarsPadding().padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Spacer(Modifier.weight(1f))
+ val big = when (ring) {
+ is Ring.AlarmRing -> Fmt.clock(now.longValue)
+ // a finished timer counts up the time since it ended
+ is Ring.TimerRing -> "−" + Fmt.duration(now.longValue - ring.since)
+ }
+ Text(big, style = WType.ringing, color = WColor.ringOn)
+ Spacer(Modifier.height(8.dp))
+ Text(label, style = WType.title, color = WColor.ringOn)
+ Spacer(Modifier.weight(1.2f))
+ val snooze = if (ring is Ring.AlarmRing) onSnooze else null
+ SlideToAct(
+ leftLabel = if (snooze != null) stringResource(R.string.snooze) else stringResource(R.string.stop),
+ rightLabel = stringResource(R.string.stop),
+ icon = if (ring is Ring.AlarmRing) Icons.Rounded.Alarm else Icons.Rounded.HourglassBottom,
+ onLeft = snooze ?: onStop, onRight = onStop,
+ )
+ Spacer(Modifier.height(32.dp))
+ }
+}
+
+/** Pill track with an alarm thumb in the middle: drag left to snooze, right to stop. */
+@Composable
+fun SlideToAct(leftLabel: String, rightLabel: String, icon: ImageVector, onLeft: () -> Unit, onRight: () -> Unit) {
+ val scope = rememberCoroutineScope()
+ val offset = remember { Animatable(0f) }
+ BoxWithConstraints(
+ Modifier.fillMaxWidth().height(76.dp).clip(RoundedCornerShape(38.dp)).background(WColor.ringTrack)
+ .semantics {
+ customActions = listOf(
+ CustomAccessibilityAction(leftLabel) { onLeft(); true },
+ CustomAccessibilityAction(rightLabel) { onRight(); true },
+ )
+ },
+ ) {
+ val maxPx = with(LocalDensity.current) { (maxWidth / 2 - 38.dp).toPx() }
+ Row(Modifier.fillMaxSize().padding(horizontal = 24.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
+ Text(leftLabel, style = WType.label, color = WColor.ringOn, modifier = Modifier.clickable(onClick = onLeft))
+ Text(rightLabel, style = WType.label, color = WColor.ringOn, modifier = Modifier.clickable(onClick = onRight))
+ }
+ Box(
+ Modifier.align(Alignment.Center).offset { IntOffset(offset.value.roundToInt(), 0) }
+ .size(width = 96.dp, height = 64.dp).clip(RoundedCornerShape(32.dp)).background(WColor.ringThumb)
+ .pointerInput(maxPx) {
+ detectHorizontalDragGestures(
+ onDragEnd = {
+ scope.launch {
+ val v = offset.value
+ when {
+ v < -maxPx * .6f -> { offset.snapTo(-maxPx); onLeft() }
+ v > maxPx * .6f -> { offset.snapTo(maxPx); onRight() }
+ else -> offset.animateTo(0f, spring(dampingRatio = .6f))
+ }
+ }
+ },
+ ) { _, drag -> scope.launch { offset.snapTo((offset.value + drag).coerceIn(-maxPx, maxPx)) } }
+ }
+ .semantics { contentDescription = "$leftLabel / $rightLabel" },
+ contentAlignment = Alignment.Center,
+ ) { Icon(icon, null, tint = WColor.onRingThumb, modifier = Modifier.size(28.dp)) }
+ }
+}
diff --git a/uhr/app/src/main/java/app/uhr/alarm/RingingService.kt b/uhr/app/src/main/java/app/uhr/alarm/RingingService.kt
new file mode 100644
index 0000000..66b0a2a
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/alarm/RingingService.kt
@@ -0,0 +1,200 @@
+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.AudioManager
+import android.media.MediaPlayer
+import android.media.RingtoneManager
+import android.net.Uri
+import android.os.IBinder
+import android.os.VibrationEffect
+import android.os.Vibrator
+import android.os.VibratorManager
+import androidx.core.app.ServiceCompat
+import app.uhr.R
+import app.uhr.data.Alarm
+import app.uhr.data.Graph
+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
+
+/** What is ringing right now, for the full-screen activity. */
+sealed interface Ring {
+ data class AlarmRing(val alarm: Alarm) : Ring
+ data class TimerRing(val timerId: Long, val label: String, val since: Long) : Ring
+}
+
+/**
+ * Plays the alarm or timer: sound on the alarm stream with gradual volume, vibration,
+ * a full-screen notification, and the "silence after" timeout.
+ */
+class RingingService : Service() {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+ private var player: MediaPlayer? = null
+ private var vibrator: Vibrator? = null
+ private var timeout: Job? = null
+ private var ramp: Job? = null
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ Graph.init(this)
+ val id = intent?.getLongExtra(AlarmReceiver.EXTRA_ID, -1) ?: -1
+ // must be in the foreground within seconds, before any database work
+ goForeground(getString(R.string.alarm), "")
+ when (intent?.action) {
+ ACTION_ALARM -> scope.launch { startAlarm(id) }
+ ACTION_TIMER -> scope.launch { startTimer(id) }
+ ACTION_SNOOZE -> scope.launch { (current.value as? Ring.AlarmRing)?.let { Graph.repo.snooze(it.alarm.id) }; stop() }
+ ACTION_STOP -> scope.launch { stopCurrent(); stop() }
+ else -> stop()
+ }
+ return START_NOT_STICKY
+ }
+
+ private suspend fun startAlarm(id: Long) {
+ val repo = Graph.repo
+ val alarm = repo.alarm(id) ?: return stop()
+ if (!alarm.enabled) return stop()
+ repo.markRinging(id)
+ val ring = Ring.AlarmRing(alarm)
+ current.value = ring
+ val title = alarm.label.ifBlank { getString(R.string.alarm) }
+ goForeground(title, String.format("%d:%02d", alarm.hour, alarm.minute), snooze = true)
+ val s = repo.prefs.settingsOnce()
+ play(alarm.ringtoneUri, s.alarmVolume, s.gradualVolumeS)
+ if (alarm.vibrate) vibrate()
+ timeout?.cancel()
+ timeout = scope.launch {
+ delay(s.silenceAfterMin * 60_000L)
+ repo.finishAlarm(id, missed = true)
+ stop()
+ }
+ }
+
+ private suspend fun startTimer(id: Long) {
+ val repo = Graph.repo
+ val t = repo.timer(id) ?: return stop()
+ repo.markTimerRinging(id)
+ current.value = Ring.TimerRing(id, t.label, t.endAt ?: System.currentTimeMillis())
+ goForeground(t.label.ifBlank { getString(R.string.timer_done) }, getString(R.string.timer_done), snooze = false)
+ val s = repo.prefs.settingsOnce()
+ play(s.timerSound, s.alarmVolume, 0)
+ if (s.timerVibrate) vibrate()
+ timeout?.cancel()
+ timeout = scope.launch { delay(s.silenceAfterMin * 60_000L); stopCurrent(); stop() }
+ }
+
+ private suspend fun stopCurrent() {
+ when (val r = current.value) {
+ is Ring.AlarmRing -> Graph.repo.finishAlarm(r.alarm.id)
+ is Ring.TimerRing -> Graph.repo.deleteTimer(r.timerId)
+ null -> Unit
+ }
+ }
+
+ private fun goForeground(title: String, text: String, snooze: Boolean = false) {
+ val full = PendingIntent.getActivity(
+ this, 0, Intent(this, RingingActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+ val n = Notifications.ringing(this, title, text, full, self(ACTION_STOP), if (snooze) self(ACTION_SNOOZE) else null)
+ ServiceCompat.startForeground(this, Notifications.ID_RINGING, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
+ }
+
+ private fun self(action: String) = PendingIntent.getService(
+ this, action.hashCode(), Intent(this, RingingService::class.java).setAction(action), PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ private fun play(uri: String?, volumePercent: Int, rampSeconds: Int) {
+ player?.release(); player = null
+ if (uri == Alarm.SILENT) return
+ val audio = getSystemService(AudioManager::class.java)
+ val target = (volumePercent / 100f).coerceIn(0.05f, 1f)
+ val candidates = listOfNotNull(
+ uri?.let(Uri::parse),
+ RingtoneManager.getActualDefaultRingtoneUri(this, RingtoneManager.TYPE_ALARM),
+ Uri.parse("android.resource://$packageName/${R.raw.alarm_default}"),
+ )
+ // a picked file may be gone: try the next one rather than ring silently
+ for (u in candidates) {
+ val p = runCatching {
+ MediaPlayer().apply {
+ setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_ALARM).setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION).build())
+ setDataSource(this@RingingService, u)
+ isLooping = true
+ prepare()
+ }
+ }.getOrNull() ?: continue
+ player = p
+ break
+ }
+ val p = player ?: return
+ if (audio.getStreamVolume(AudioManager.STREAM_ALARM) == 0) {
+ audio.setStreamVolume(AudioManager.STREAM_ALARM, audio.getStreamMaxVolume(AudioManager.STREAM_ALARM) / 2, 0)
+ }
+ p.setVolume(if (rampSeconds > 0) 0.05f else target, if (rampSeconds > 0) 0.05f else target)
+ p.start()
+ ramp?.cancel()
+ if (rampSeconds > 0) ramp = scope.launch {
+ val steps = rampSeconds * 4
+ for (i in 1..steps) {
+ delay(250)
+ val v = 0.05f + (target - 0.05f) * i / steps
+ runCatching { p.setVolume(v, v) }
+ }
+ }
+ }
+
+ private fun vibrate() {
+ val v = getSystemService(VibratorManager::class.java)?.defaultVibrator ?: return
+ vibrator = v
+ v.vibrate(VibrationEffect.createWaveform(longArrayOf(0, 600, 400, 600, 1200), 0),
+ android.os.VibrationAttributes.createForUsage(android.os.VibrationAttributes.USAGE_ALARM))
+ }
+
+ private fun stop() {
+ timeout?.cancel(); ramp?.cancel()
+ player?.runCatching { stop(); release() }; player = null
+ vibrator?.cancel(); vibrator = null
+ current.value = null
+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+
+ override fun onDestroy() {
+ player?.release(); vibrator?.cancel(); current.value = null
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ companion object {
+ private const val ACTION_ALARM = "alarm"
+ private const val ACTION_TIMER = "timer"
+ const val ACTION_SNOOZE = "snooze"
+ const val ACTION_STOP = "stop"
+
+ private val _current = MutableStateFlow(null)
+ val current: MutableStateFlow get() = _current
+ val ringing: StateFlow get() = _current
+
+ fun startAlarm(context: Context, id: Long) = start(context, ACTION_ALARM, id)
+ fun startTimer(context: Context, id: Long) = start(context, ACTION_TIMER, id)
+ fun snooze(context: Context) = start(context, ACTION_SNOOZE, -1)
+ fun stop(context: Context) = start(context, ACTION_STOP, -1)
+
+ private fun start(context: Context, action: String, id: Long) = context.startForegroundService(
+ Intent(context, RingingService::class.java).setAction(action).putExtra(AlarmReceiver.EXTRA_ID, id),
+ )
+ }
+}
diff --git a/uhr/app/src/main/java/app/uhr/alarm/Scheduler.kt b/uhr/app/src/main/java/app/uhr/alarm/Scheduler.kt
new file mode 100644
index 0000000..9607f3e
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/alarm/Scheduler.kt
@@ -0,0 +1,93 @@
+package app.uhr.alarm
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import app.uhr.MainActivity
+import app.uhr.data.Alarm
+import app.uhr.data.AlarmInstance
+import app.uhr.data.ClockDao
+import app.uhr.data.InstanceState
+import app.uhr.data.Timer
+import app.uhr.data.nextRing
+import java.time.ZonedDateTime
+
+/** All AlarmManager access. Alarms use setAlarmClock: Doze-exempt and shown in the status bar. */
+class Scheduler(private val context: Context, private val dao: ClockDao) {
+ private val am = context.getSystemService(AlarmManager::class.java)
+
+ suspend fun reschedule(alarm: Alarm, now: ZonedDateTime = ZonedDateTime.now()) {
+ cancel(alarm.id)
+ if (!alarm.enabled) { dao.deleteInstance(alarm.id); Notifications.cancelAlarm(context, alarm.id); return }
+ val fireAt = nextRing(alarm, now).toInstant().toEpochMilli()
+ dao.putInstance(AlarmInstance(alarm.id, fireAt, InstanceState.SCHEDULED))
+ setRing(alarm.id, fireAt)
+ scheduleUpcomingNotice(alarm.id, fireAt)
+ Notifications.cancelAlarm(context, alarm.id)
+ }
+
+ suspend fun snooze(alarmId: Long, minutes: Int) {
+ val fireAt = System.currentTimeMillis() + minutes * 60_000L
+ dao.putInstance(AlarmInstance(alarmId, fireAt, InstanceState.SNOOZED))
+ setRing(alarmId, fireAt)
+ }
+
+ /** After a reboot, a time change or an app update: every alarm from scratch, snoozes kept. */
+ suspend fun rescheduleAll() {
+ val instances = dao.instancesOnce().associateBy { it.alarmId }
+ val now = System.currentTimeMillis()
+ for (alarm in dao.alarmsOnce()) {
+ val snoozed = instances[alarm.id]?.takeIf { it.state == InstanceState.SNOOZED && it.fireAt > now }
+ if (alarm.enabled && snoozed != null) setRing(alarm.id, snoozed.fireAt) else reschedule(alarm)
+ }
+ dao.timersOnce().filter { it.endAt != null && it.state == app.uhr.data.TimerState.RUNNING }.forEach { scheduleTimer(it) }
+ }
+
+ fun cancel(alarmId: Long) {
+ am.cancel(ringIntent(alarmId))
+ am.cancel(upcomingIntent(alarmId))
+ }
+
+ private fun setRing(alarmId: Long, fireAt: Long) {
+ val show = PendingIntent.getActivity(
+ context, alarmId.toInt(), Intent(context, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+ am.setAlarmClock(AlarmManager.AlarmClockInfo(fireAt, show), ringIntent(alarmId))
+ }
+
+ private fun scheduleUpcomingNotice(alarmId: Long, fireAt: Long) {
+ val at = fireAt - UPCOMING_MS
+ if (at > System.currentTimeMillis()) am.set(AlarmManager.RTC_WAKEUP, at, upcomingIntent(alarmId))
+ }
+
+ fun scheduleTimer(timer: Timer) {
+ val end = timer.endAt ?: return
+ am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, end, timerIntent(timer.id))
+ }
+
+ fun cancelTimer(id: Long) = am.cancel(timerIntent(id))
+
+ fun scheduleBedtimeReminder(at: Long) =
+ am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, broadcast(AlarmReceiver.ACTION_BEDTIME, 0, REQ_BEDTIME))
+
+ fun cancelBedtimeReminder() = am.cancel(broadcast(AlarmReceiver.ACTION_BEDTIME, 0, REQ_BEDTIME))
+
+ private fun ringIntent(id: Long) = broadcast(AlarmReceiver.ACTION_RING, id, REQ_RING)
+ private fun upcomingIntent(id: Long) = broadcast(AlarmReceiver.ACTION_UPCOMING, id, REQ_UPCOMING)
+ private fun timerIntent(id: Long) = broadcast(AlarmReceiver.ACTION_TIMER, id, REQ_TIMER)
+
+ private fun broadcast(action: String, id: Long, base: Int): PendingIntent = PendingIntent.getBroadcast(
+ context, base + id.toInt(),
+ Intent(context, AlarmReceiver::class.java).setAction(action).putExtra(AlarmReceiver.EXTRA_ID, id),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+
+ companion object {
+ const val UPCOMING_MS = 2 * 60 * 60 * 1000L
+ private const val REQ_RING = 0
+ private const val REQ_UPCOMING = 1_000_000
+ private const val REQ_TIMER = 2_000_000
+ private const val REQ_BEDTIME = 3_000_000
+ }
+}
diff --git a/uhr/app/src/main/java/app/uhr/data/ClockRepo.kt b/uhr/app/src/main/java/app/uhr/data/ClockRepo.kt
new file mode 100644
index 0000000..d3fbae2
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/data/ClockRepo.kt
@@ -0,0 +1,195 @@
+package app.uhr.data
+
+import android.content.Context
+import android.os.SystemClock
+import app.uhr.alarm.Notifications
+import app.uhr.alarm.Scheduler
+import app.uhr.widget.updateClockWidgets
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import java.time.LocalDate
+import java.time.ZonedDateTime
+
+class ClockRepo(private val context: Context, private val dao: ClockDao, val prefs: Prefs) {
+ val scheduler = Scheduler(context, dao)
+ /** Ringing, snooze and dismiss can race (notification vs. screen); every transition goes through here. */
+ private val lock = Mutex()
+
+ // --- alarms -------------------------------------------------------------------------------
+
+ val alarms: Flow> = dao.alarms()
+ val nextInstance: Flow = dao.nextInstance()
+ suspend fun alarm(id: Long) = dao.alarm(id)
+ suspend fun instance(id: Long) = dao.instance(id)
+ suspend fun nextInstanceOnce() = dao.nextInstanceOnce()
+
+ 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) }
+ updateClockWidgets(context)
+ id
+ }
+
+ suspend fun delete(alarm: Alarm) = lock.withLock {
+ scheduler.cancel(alarm.id)
+ Notifications.cancelAlarm(context, alarm.id)
+ dao.delete(alarm)
+ updateClockWidgets(context)
+ }
+
+ /** Re-insert after "undo"; keeps the id so nothing else has to change. */
+ suspend fun restore(alarm: Alarm) = lock.withLock {
+ dao.insert(alarm)
+ scheduler.reschedule(alarm)
+ updateClockWidgets(context)
+ }
+
+ suspend fun snooze(id: Long) = lock.withLock {
+ val minutes = prefs.settingsOnce().snoozeMin
+ scheduler.snooze(id, minutes)
+ dao.alarm(id)?.let { a -> dao.instance(id)?.let { Notifications.snoozed(context, a, it.fireAt) } }
+ updateClockWidgets(context)
+ }
+
+ /** Stop: a one-time alarm switches off, a repeating one moves to its next day. */
+ suspend fun finishAlarm(id: Long, missed: Boolean = false) = lock.withLock {
+ val alarm = dao.alarm(id) ?: return@withLock
+ Notifications.cancelAlarm(context, id)
+ if (missed) Notifications.missed(context, alarm)
+ if (alarm.repeats) scheduler.reschedule(alarm)
+ else {
+ val off = alarm.copy(enabled = false, skipUntil = null)
+ dao.update(off); scheduler.reschedule(off)
+ }
+ updateClockWidgets(context)
+ }
+
+ /** Skip only the upcoming ring ("dismiss now" before it rings). */
+ suspend fun dismissNext(id: Long) = lock.withLock {
+ val alarm = dao.alarm(id) ?: return@withLock
+ val inst = dao.instance(id) ?: return@withLock
+ Notifications.cancelAlarm(context, id)
+ if (!alarm.repeats) { val off = alarm.copy(enabled = false); dao.update(off); scheduler.reschedule(off) }
+ else {
+ val after = java.time.Instant.ofEpochMilli(inst.fireAt).atZone(java.time.ZoneId.systemDefault()).plusMinutes(1)
+ scheduler.reschedule(alarm, after)
+ }
+ updateClockWidgets(context)
+ }
+
+ suspend fun markRinging(id: Long) = lock.withLock {
+ dao.instance(id)?.let { dao.putInstance(it.copy(state = InstanceState.RINGING, updatedAt = System.currentTimeMillis())) }
+ }
+
+ // --- timers -------------------------------------------------------------------------------
+
+ val timers: Flow> = dao.timers()
+ suspend fun timer(id: Long) = dao.timer(id)
+
+ suspend fun startTimer(durationMs: Long, label: String = ""): Long {
+ val end = System.currentTimeMillis() + durationMs
+ val id = dao.insert(Timer(label = label, durationMs = durationMs, remainingMs = durationMs, endAt = end, state = TimerState.RUNNING))
+ dao.timer(id)?.let { scheduler.scheduleTimer(it); Notifications.timers(context, dao.timersOnce()) }
+ return id
+ }
+
+ suspend fun pauseTimer(id: Long) = updateTimer(id) { t ->
+ if (t.state != TimerState.RUNNING) t else t.copy(state = TimerState.PAUSED, remainingMs = t.remaining().coerceAtLeast(0), endAt = null)
+ }
+
+ suspend fun resumeTimer(id: Long) = updateTimer(id) { t ->
+ if (t.state != TimerState.PAUSED) t else t.copy(state = TimerState.RUNNING, endAt = System.currentTimeMillis() + t.remainingMs)
+ }
+
+ suspend fun addMinute(id: Long) = updateTimer(id) { t ->
+ when (t.state) {
+ TimerState.PAUSED -> t.copy(remainingMs = t.remainingMs + 60_000, durationMs = t.durationMs + 60_000)
+ // a ringing timer restarts with one minute
+ TimerState.RINGING -> t.copy(state = TimerState.RUNNING, endAt = System.currentTimeMillis() + 60_000, durationMs = 60_000, remainingMs = 60_000)
+ TimerState.RUNNING -> t.copy(endAt = (t.endAt ?: System.currentTimeMillis()) + 60_000, durationMs = t.durationMs + 60_000)
+ }
+ }
+
+ suspend fun resetTimer(id: Long) = updateTimer(id) { t ->
+ t.copy(state = TimerState.PAUSED, remainingMs = t.durationMs, endAt = null)
+ }
+
+ suspend fun setTimerLabel(id: Long, label: String) = updateTimer(id) { it.copy(label = label) }
+
+ suspend fun markTimerRinging(id: Long) = updateTimer(id) { it.copy(state = TimerState.RINGING) }
+
+ suspend fun deleteTimer(id: Long) {
+ scheduler.cancelTimer(id); dao.deleteTimer(id)
+ Notifications.timers(context, dao.timersOnce())
+ }
+
+ private suspend fun updateTimer(id: Long, f: (Timer) -> Timer) {
+ val t = dao.timer(id) ?: return
+ val n = f(t)
+ dao.update(n)
+ scheduler.cancelTimer(id)
+ if (n.state == TimerState.RUNNING) scheduler.scheduleTimer(n)
+ Notifications.timers(context, dao.timersOnce())
+ }
+
+ // --- stopwatch ----------------------------------------------------------------------------
+
+ val stopwatch: Flow = prefs.stopwatch
+
+ suspend fun stopwatchToggle() {
+ val s = prefs.stopwatch.first()
+ val now = SystemClock.elapsedRealtime()
+ val next = when (s.state) {
+ StopwatchState.RUNNING -> s.copy(state = StopwatchState.STOPPED, accumulatedMs = s.elapsed(now))
+ else -> s.copy(state = StopwatchState.RUNNING, startedAt = now)
+ }
+ prefs.setStopwatch(next)
+ Notifications.stopwatch(context, next)
+ }
+
+ suspend fun stopwatchLap() {
+ val s = prefs.stopwatch.first()
+ if (s.state != StopwatchState.RUNNING) return
+ prefs.setStopwatch(s.copy(laps = s.laps + s.elapsed(SystemClock.elapsedRealtime())))
+ }
+
+ suspend fun stopwatchReset() {
+ prefs.setStopwatch(Stopwatch())
+ Notifications.stopwatch(context, Stopwatch())
+ }
+
+ // --- world clock --------------------------------------------------------------------------
+
+ val cities: Flow> = dao.cities()
+ suspend fun addCity(hit: ZoneHit) { dao.insert(City(zoneId = hit.zoneId, name = hit.city, sortOrder = dao.nextCityOrder())); updateClockWidgets(context) }
+ suspend fun deleteCity(id: Long) { dao.deleteCity(id); updateClockWidgets(context) }
+ suspend fun reorderCities(ids: List) = dao.reorderCities(ids)
+
+ // --- bedtime ------------------------------------------------------------------------------
+
+ val bedtime: Flow = prefs.bedtime
+
+ /** Bedtime owns exactly one alarm (is_bedtime_wake) and one reminder. */
+ suspend fun saveBedtime(b: Bedtime) {
+ prefs.setBedtime(b)
+ val existing = dao.bedtimeAlarm()
+ val wake = (existing ?: Alarm(hour = b.wakeHour, minute = b.wakeMinute, isBedtimeWake = true))
+ .copy(hour = b.wakeHour, minute = b.wakeMinute, days = b.days, enabled = b.enabled, isBedtimeWake = true)
+ save(wake)
+ scheduleBedtime()
+ }
+
+ suspend fun scheduleBedtime() {
+ val b = prefs.bedtime.first()
+ scheduler.cancelBedtimeReminder()
+ if (!b.enabled || b.reminderMin < 0) return
+ val reminder = Alarm(hour = b.bedHour, minute = b.bedMinute, days = b.days)
+ val at = nextRing(reminder, ZonedDateTime.now().plusMinutes(b.reminderMin.toLong())).minusMinutes(b.reminderMin.toLong())
+ scheduler.scheduleBedtimeReminder(at.toInstant().toEpochMilli())
+ }
+
+ fun today(): LocalDate = LocalDate.now()
+}
diff --git a/uhr/app/src/main/java/app/uhr/data/Db.kt b/uhr/app/src/main/java/app/uhr/data/Db.kt
new file mode 100644
index 0000000..29c977e
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/data/Db.kt
@@ -0,0 +1,81 @@
+package app.uhr.data
+
+import android.content.Context
+import androidx.room.Dao
+import androidx.room.Database
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.room.Transaction
+import androidx.room.TypeConverter
+import androidx.room.TypeConverters
+import androidx.room.Update
+import androidx.room.Upsert
+import kotlinx.coroutines.flow.Flow
+import java.time.LocalDate
+
+class Converters {
+ @TypeConverter fun fromDate(d: LocalDate?): String? = d?.toString()
+ @TypeConverter fun toDate(s: String?): LocalDate? = s?.let(LocalDate::parse)
+ @TypeConverter fun fromState(s: InstanceState): String = s.name.lowercase()
+ @TypeConverter fun toState(s: String): InstanceState = InstanceState.valueOf(s.uppercase())
+ @TypeConverter fun fromTimerState(s: TimerState): String = s.name.lowercase()
+ @TypeConverter fun toTimerState(s: String): TimerState = TimerState.valueOf(s.uppercase())
+}
+
+data class AlarmWithInstance(
+ @androidx.room.Embedded val alarm: Alarm,
+ @androidx.room.Relation(parentColumn = "id", entityColumn = "alarm_id") val instance: AlarmInstance?,
+)
+
+@Dao
+interface ClockDao {
+ @Transaction
+ @Query("SELECT * FROM alarms ORDER BY is_bedtime_wake DESC, hour, minute, id")
+ fun alarms(): Flow>
+
+ @Query("SELECT * FROM alarms") suspend fun alarmsOnce(): List
+ @Query("SELECT * FROM alarms WHERE id = :id") suspend fun alarm(id: Long): Alarm?
+ @Query("SELECT * FROM alarms WHERE is_bedtime_wake = 1 LIMIT 1") suspend fun bedtimeAlarm(): Alarm?
+ @Insert suspend fun insert(alarm: Alarm): Long
+ @Update suspend fun update(alarm: Alarm)
+ @Delete suspend fun delete(alarm: Alarm)
+
+ @Query("SELECT * FROM alarm_instances WHERE alarm_id = :id") suspend fun instance(id: Long): AlarmInstance?
+ @Query("SELECT * FROM alarm_instances") suspend fun instancesOnce(): List
+ @Upsert suspend fun putInstance(instance: AlarmInstance)
+ @Query("DELETE FROM alarm_instances WHERE alarm_id = :id") suspend fun deleteInstance(id: Long)
+
+ /** The soonest alarm that will actually ring, for widgets and the clock header. */
+ @Query("SELECT * FROM alarm_instances WHERE state IN ('scheduled','snoozed') ORDER BY fire_at LIMIT 1")
+ fun nextInstance(): Flow
+ @Query("SELECT * FROM alarm_instances WHERE state IN ('scheduled','snoozed') ORDER BY fire_at LIMIT 1")
+ suspend fun nextInstanceOnce(): AlarmInstance?
+
+ @Query("SELECT * FROM timers ORDER BY created_at") fun timers(): Flow>
+ @Query("SELECT * FROM timers ORDER BY created_at") suspend fun timersOnce(): List
+ @Query("SELECT * FROM timers WHERE id = :id") suspend fun timer(id: Long): Timer?
+ @Insert suspend fun insert(timer: Timer): Long
+ @Update suspend fun update(timer: Timer)
+ @Query("DELETE FROM timers WHERE id = :id") suspend fun deleteTimer(id: Long)
+
+ @Query("SELECT * FROM cities ORDER BY sort_order, id") fun cities(): Flow>
+ @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insert(city: City): Long
+ @Query("DELETE FROM cities WHERE id = :id") suspend fun deleteCity(id: Long)
+ @Query("SELECT COALESCE(MAX(sort_order), 0) + 1 FROM cities") suspend fun nextCityOrder(): Int
+ @Query("UPDATE cities SET sort_order = :order WHERE id = :id") suspend fun setCityOrder(id: Long, order: Int)
+ @Transaction suspend fun reorderCities(ids: List) = ids.forEachIndexed { i, id -> setCityOrder(id, i) }
+}
+
+@Database(entities = [Alarm::class, AlarmInstance::class, Timer::class, City::class], version = 1, exportSchema = true)
+@TypeConverters(Converters::class)
+abstract class ClockDb : RoomDatabase() {
+ abstract fun dao(): ClockDao
+
+ companion object {
+ fun create(context: Context) = Room.databaseBuilder(context, ClockDb::class.java, "clock.db").build()
+ }
+}
diff --git a/uhr/app/src/main/java/app/uhr/data/Graph.kt b/uhr/app/src/main/java/app/uhr/data/Graph.kt
new file mode 100644
index 0000000..33be13f
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/data/Graph.kt
@@ -0,0 +1,13 @@
+package app.uhr.data
+
+import android.content.Context
+
+object Graph {
+ lateinit var repo: ClockRepo private set
+
+ fun init(context: Context) {
+ if (::repo.isInitialized) return
+ val app = context.applicationContext
+ repo = ClockRepo(app, ClockDb.create(app).dao(), Prefs(app))
+ }
+}
diff --git a/uhr/app/src/main/java/app/uhr/data/Model.kt b/uhr/app/src/main/java/app/uhr/data/Model.kt
new file mode 100644
index 0000000..bf1fa4b
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/data/Model.kt
@@ -0,0 +1,82 @@
+package app.uhr.data
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import java.time.DayOfWeek
+import java.time.LocalDate
+
+@Entity(tableName = "alarms", indices = [Index("hour", "minute")])
+data class Alarm(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ val hour: Int,
+ val minute: Int,
+ val enabled: Boolean = true,
+ /** bit 0 = Monday … bit 6 = Sunday; 0 = rings once */
+ val days: Int = 0,
+ val label: String = "",
+ /** null = default sound, [SILENT] = no sound */
+ @ColumnInfo(name = "ringtone_uri") val ringtoneUri: String? = null,
+ val vibrate: Boolean = true,
+ /** paused until and including this date */
+ @ColumnInfo(name = "skip_until") val skipUntil: LocalDate? = null,
+ @ColumnInfo(name = "is_bedtime_wake") val isBedtimeWake: Boolean = false,
+ @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
+ @ColumnInfo(name = "updated_at") val updatedAt: Long = System.currentTimeMillis(),
+) {
+ val repeats: Boolean get() = days != 0
+ fun ringsOn(day: DayOfWeek): Boolean = days and (1 shl (day.value - 1)) != 0
+ fun withDay(day: DayOfWeek, on: Boolean): Alarm =
+ copy(days = if (on) days or (1 shl (day.value - 1)) else days and (1 shl (day.value - 1)).inv())
+
+ companion object {
+ const val SILENT = "silent"
+ const val WEEKDAYS = 0b0011111
+ const val EVERY_DAY = 0b1111111
+ }
+}
+
+enum class InstanceState { SCHEDULED, SNOOZED, RINGING, MISSED, DISMISSED }
+
+/** The next concrete ring of an alarm; snooze and "dismiss early" live here. */
+@Entity(
+ tableName = "alarm_instances",
+ foreignKeys = [ForeignKey(Alarm::class, ["id"], ["alarm_id"], onDelete = ForeignKey.CASCADE)],
+ indices = [Index("state", "fire_at")],
+)
+data class AlarmInstance(
+ @PrimaryKey @ColumnInfo(name = "alarm_id") val alarmId: Long,
+ @ColumnInfo(name = "fire_at") val fireAt: Long,
+ val state: InstanceState = InstanceState.SCHEDULED,
+ @ColumnInfo(name = "updated_at") val updatedAt: Long = System.currentTimeMillis(),
+)
+
+enum class TimerState { RUNNING, PAUSED, RINGING }
+
+@Entity(tableName = "timers", indices = [Index("state", "end_at")])
+data class Timer(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ val label: String = "",
+ @ColumnInfo(name = "duration_ms") val durationMs: Long,
+ /** valid while paused */
+ @ColumnInfo(name = "remaining_ms") val remainingMs: Long,
+ /** set while running or ringing */
+ @ColumnInfo(name = "end_at") val endAt: Long?,
+ val state: TimerState,
+ @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
+) {
+ fun remaining(now: Long = System.currentTimeMillis()): Long = when (state) {
+ TimerState.PAUSED -> remainingMs
+ else -> (endAt ?: now) - now
+ }
+}
+
+@Entity(tableName = "cities", indices = [Index(value = ["zone_id"], unique = true), Index("sort_order")])
+data class City(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ @ColumnInfo(name = "zone_id") val zoneId: String,
+ val name: String,
+ @ColumnInfo(name = "sort_order") val sortOrder: Int = 0,
+)
diff --git a/uhr/app/src/main/java/app/uhr/data/NextRing.kt b/uhr/app/src/main/java/app/uhr/data/NextRing.kt
new file mode 100644
index 0000000..3642bbb
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/data/NextRing.kt
@@ -0,0 +1,24 @@
+package app.uhr.data
+
+import java.time.LocalDateTime
+import java.time.ZoneId
+import java.time.ZonedDateTime
+
+/**
+ * When an alarm rings next, strictly after [now]. Wall-clock based: 7:00 stays 7:00 across
+ * time zone and DST changes. A time that does not exist (spring forward) rings at the first valid
+ * instant after it, which is what ZonedDateTime.of does.
+ */
+fun nextRing(alarm: Alarm, now: ZonedDateTime): ZonedDateTime {
+ val zone: ZoneId = now.zone
+ var date = now.toLocalDate()
+ val skipUntil = alarm.skipUntil
+ repeat(400) {
+ val candidate = ZonedDateTime.of(LocalDateTime.of(date, java.time.LocalTime.of(alarm.hour, alarm.minute)), zone)
+ val dayOk = !alarm.repeats || alarm.ringsOn(date.dayOfWeek)
+ val notSkipped = skipUntil == null || date.isAfter(skipUntil)
+ if (candidate.isAfter(now) && dayOk && notSkipped) return candidate
+ date = date.plusDays(1)
+ }
+ error("no next ring for $alarm")
+}
diff --git a/uhr/app/src/main/java/app/uhr/data/Prefs.kt b/uhr/app/src/main/java/app/uhr/data/Prefs.kt
new file mode 100644
index 0000000..35cdbda
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/data/Prefs.kt
@@ -0,0 +1,158 @@
+package app.uhr.data
+
+import android.content.Context
+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.longPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+
+enum class ThemeMode { SYSTEM, LIGHT, DARK }
+enum class VolumeButtons { VOLUME, SNOOZE, STOP, NOTHING }
+enum class ClockStyle { DIGITAL, ANALOG }
+
+data class Settings(
+ val silenceAfterMin: Int = 10,
+ val snoozeMin: Int = 10,
+ /** 0..100, share of the alarm stream's max volume */
+ val alarmVolume: Int = 80,
+ /** seconds to ramp up from quiet; 0 = off */
+ val gradualVolumeS: Int = 30,
+ val volumeButtons: VolumeButtons = VolumeButtons.VOLUME,
+ /** java.time.DayOfWeek value, 1 = Monday */
+ val weekStart: Int = 1,
+ val clockStyle: ClockStyle = ClockStyle.DIGITAL,
+ val showSeconds: Boolean = false,
+ val autoHomeClock: Boolean = true,
+ val homeZone: String? = null,
+ val timerSound: String? = null,
+ val timerVibrate: Boolean = true,
+ val theme: ThemeMode = ThemeMode.SYSTEM,
+)
+
+enum class StopwatchState { IDLE, RUNNING, STOPPED }
+
+/** Timestamps only (elapsedRealtime), never a running counter, so process death changes nothing. */
+data class Stopwatch(
+ val state: StopwatchState = StopwatchState.IDLE,
+ val startedAt: Long = 0,
+ val accumulatedMs: Long = 0,
+ val laps: List = emptyList(),
+) {
+ fun elapsed(now: Long): Long = accumulatedMs + if (state == StopwatchState.RUNNING) now - startedAt else 0
+}
+
+data class Bedtime(
+ val enabled: Boolean = false,
+ val bedHour: Int = 23,
+ val bedMinute: Int = 0,
+ val wakeHour: Int = 7,
+ val wakeMinute: Int = 0,
+ val days: Int = Alarm.WEEKDAYS,
+ val reminderMin: Int = 15,
+)
+
+private val Context.store by preferencesDataStore("clock")
+
+class Prefs(private val context: Context) {
+ private object K {
+ val silence = intPreferencesKey("silence_after")
+ val snooze = intPreferencesKey("snooze")
+ val volume = intPreferencesKey("alarm_volume")
+ val gradual = intPreferencesKey("gradual")
+ val buttons = stringPreferencesKey("volume_buttons")
+ val weekStart = intPreferencesKey("week_start")
+ val style = stringPreferencesKey("clock_style")
+ val seconds = booleanPreferencesKey("seconds")
+ val autoHome = booleanPreferencesKey("auto_home")
+ val homeZone = stringPreferencesKey("home_zone")
+ val timerSound = stringPreferencesKey("timer_sound")
+ val timerVibrate = booleanPreferencesKey("timer_vibrate")
+ val theme = stringPreferencesKey("theme")
+ val swState = stringPreferencesKey("sw_state")
+ val swStarted = longPreferencesKey("sw_started")
+ val swAcc = longPreferencesKey("sw_acc")
+ val swLaps = stringPreferencesKey("sw_laps")
+ val bedOn = booleanPreferencesKey("bed_on")
+ val bedH = intPreferencesKey("bed_h")
+ val bedM = intPreferencesKey("bed_m")
+ val wakeH = intPreferencesKey("wake_h")
+ val wakeM = intPreferencesKey("wake_m")
+ val bedDays = intPreferencesKey("bed_days")
+ val bedReminder = intPreferencesKey("bed_reminder")
+ }
+
+ private fun Preferences.settings(): Settings {
+ val d = Settings()
+ return Settings(
+ silenceAfterMin = this[K.silence] ?: d.silenceAfterMin,
+ snoozeMin = this[K.snooze] ?: d.snoozeMin,
+ alarmVolume = this[K.volume] ?: d.alarmVolume,
+ gradualVolumeS = this[K.gradual] ?: d.gradualVolumeS,
+ volumeButtons = this[K.buttons].enumOr(d.volumeButtons),
+ weekStart = this[K.weekStart] ?: d.weekStart,
+ clockStyle = this[K.style].enumOr(d.clockStyle),
+ showSeconds = this[K.seconds] ?: d.showSeconds,
+ autoHomeClock = this[K.autoHome] ?: d.autoHomeClock,
+ homeZone = this[K.homeZone],
+ timerSound = this[K.timerSound],
+ timerVibrate = this[K.timerVibrate] ?: d.timerVibrate,
+ theme = this[K.theme].enumOr(d.theme),
+ )
+ }
+
+ val settings: Flow = context.store.data.map { it.settings() }
+ suspend fun settingsOnce(): Settings = settings.first()
+
+ suspend fun update(transform: (Settings) -> Settings) {
+ context.store.edit { p ->
+ val s = transform(p.settings())
+ p[K.silence] = s.silenceAfterMin; p[K.snooze] = s.snoozeMin; p[K.volume] = s.alarmVolume
+ p[K.gradual] = s.gradualVolumeS; p[K.buttons] = s.volumeButtons.name; p[K.weekStart] = s.weekStart
+ p[K.style] = s.clockStyle.name; p[K.seconds] = s.showSeconds; p[K.autoHome] = s.autoHomeClock
+ if (s.homeZone != null) p[K.homeZone] = s.homeZone else p.remove(K.homeZone)
+ if (s.timerSound != null) p[K.timerSound] = s.timerSound else p.remove(K.timerSound)
+ p[K.timerVibrate] = s.timerVibrate; p[K.theme] = s.theme.name
+ }
+ }
+
+ val stopwatch: Flow = context.store.data.map { p ->
+ Stopwatch(
+ state = p[K.swState].enumOr(StopwatchState.IDLE),
+ startedAt = p[K.swStarted] ?: 0,
+ accumulatedMs = p[K.swAcc] ?: 0,
+ laps = p[K.swLaps]?.split(',')?.filter { it.isNotBlank() }?.map { it.toLong() } ?: emptyList(),
+ )
+ }
+
+ suspend fun setStopwatch(s: Stopwatch) {
+ context.store.edit { p ->
+ p[K.swState] = s.state.name; p[K.swStarted] = s.startedAt; p[K.swAcc] = s.accumulatedMs
+ p[K.swLaps] = s.laps.joinToString(",")
+ }
+ }
+
+ val bedtime: Flow = context.store.data.map { p ->
+ val d = Bedtime()
+ 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,
+ )
+ }
+
+ 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
+ }
+ }
+}
+
+private inline fun > String?.enumOr(default: E): E =
+ this?.let { runCatching { enumValueOf(it) }.getOrNull() } ?: default
diff --git a/uhr/app/src/main/java/app/uhr/data/Zones.kt b/uhr/app/src/main/java/app/uhr/data/Zones.kt
new file mode 100644
index 0000000..e11b001
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/data/Zones.kt
@@ -0,0 +1,34 @@
+package app.uhr.data
+
+import java.time.ZoneId
+import java.util.Locale
+
+data class ZoneHit(val zoneId: String, val city: String, val region: String)
+
+/** Offline city search over the system time zone database; names localized by ICU. */
+object Zones {
+ private val all: List by lazy {
+ ZoneId.getAvailableZoneIds()
+ .filter { it.contains('/') && !it.startsWith("Etc/") && !it.startsWith("SystemV/") && it.first().isUpperCase() }
+ .mapNotNull { id ->
+ val parts = id.split('/')
+ val city = cityName(id) ?: parts.last().replace('_', ' ')
+ ZoneHit(id, city, parts.first())
+ }
+ .distinctBy { it.city to it.region }
+ .sortedBy { it.city }
+ }
+
+ fun cityName(zoneId: String, locale: Locale = Locale.getDefault()): String? =
+ runCatching {
+ android.icu.text.TimeZoneNames.getInstance(android.icu.util.ULocale.forLocale(locale)).getExemplarLocationName(zoneId)
+ }.getOrNull()?.takeIf { it.isNotBlank() }
+
+ fun search(query: String): List {
+ val q = query.trim()
+ if (q.isEmpty()) return all.take(50)
+ return all.filter { it.city.contains(q, ignoreCase = true) || it.zoneId.contains(q.replace(' ', '_'), ignoreCase = true) }
+ .sortedBy { if (it.city.startsWith(q, ignoreCase = true)) 0 else 1 }
+ .take(50)
+ }
+}
diff --git a/uhr/app/src/main/java/app/uhr/ui/components/AnalogClock.kt b/uhr/app/src/main/java/app/uhr/ui/components/AnalogClock.kt
new file mode 100644
index 0000000..2154e79
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/ui/components/AnalogClock.kt
@@ -0,0 +1,38 @@
+package app.uhr.ui.components
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.unit.Dp
+import java.time.ZonedDateTime
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.sin
+
+/** Our own face: filled disc, thick rounded hour hand, long minute hand, optional second hand. */
+@Composable
+fun AnalogClock(time: ZonedDateTime, size: Dp, face: Color, hourHand: Color, minuteHand: Color, secondHand: Color?, modifier: Modifier = Modifier) {
+ Canvas(modifier.size(size)) { drawAnalog(time.hour, time.minute, time.second, face, hourHand, minuteHand, secondHand) }
+}
+
+fun DrawScope.drawAnalog(h: Int, m: Int, s: Int, face: Color, hourHand: Color, minuteHand: Color, secondHand: Color?) {
+ val r = size.minDimension / 2
+ val c = center
+ drawCircle(face, r, c)
+ for (i in 0 until 12) {
+ val a = 2 * PI * i / 12
+ drawCircle(minuteHand.copy(alpha = .35f), r * .025f, Offset(c.x + r * .86f * sin(a).toFloat(), c.y - r * .86f * cos(a).toFloat()))
+ }
+ fun hand(angle: Double, length: Float, width: Float, color: Color) {
+ drawLine(color, c, Offset(c.x + length * sin(angle).toFloat(), c.y - length * cos(angle).toFloat()), width, StrokeCap.Round)
+ }
+ hand(2 * PI * ((h % 12) + m / 60.0) / 12, r * .5f, r * .11f, hourHand)
+ hand(2 * PI * (m + s / 60.0) / 60, r * .74f, r * .06f, minuteHand)
+ if (secondHand != null) hand(2 * PI * s / 60, r * .8f, r * .02f, secondHand)
+ drawCircle(minuteHand, r * .06f, c)
+}
diff --git a/uhr/app/src/main/java/app/uhr/ui/components/Fmt.kt b/uhr/app/src/main/java/app/uhr/ui/components/Fmt.kt
new file mode 100644
index 0000000..f01297f
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/ui/components/Fmt.kt
@@ -0,0 +1,102 @@
+package app.uhr.ui.components
+
+import android.content.res.Resources
+import android.text.format.DateFormat
+import app.uhr.R
+import java.time.DayOfWeek
+import java.time.Duration
+import java.time.Instant
+import java.time.LocalDate
+import java.time.LocalTime
+import java.time.ZoneId
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+import java.time.format.TextStyle
+import java.util.Locale
+import kotlin.math.abs
+
+object Fmt {
+ /** Set once from the system setting (DateFormat.is24HourFormat). */
+ var is24h: Boolean = true
+
+ fun time(h: Int, m: Int): String =
+ if (is24h) String.format(Locale.getDefault(), "%d:%02d", h, m)
+ else String.format(Locale.getDefault(), "%d:%02d", if (h % 12 == 0) 12 else h % 12, m)
+
+ fun ampm(h: Int): String? = if (is24h) null else if (h < 12) "AM" else "PM"
+
+ fun clock(at: Long, zone: ZoneId = ZoneId.systemDefault(), seconds: Boolean = false): String {
+ val t = Instant.ofEpochMilli(at).atZone(zone)
+ val base = time(t.hour, t.minute)
+ return if (seconds) base + String.format(Locale.getDefault(), ":%02d", t.second) else base
+ }
+
+ fun longDate(at: Long): String =
+ Instant.ofEpochMilli(at).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("EEE, d. MMMM", Locale.getDefault()))
+
+ fun dayShort(d: DayOfWeek): String = d.getDisplayName(TextStyle.SHORT, Locale.getDefault()).trimEnd('.')
+ fun dayNarrow(d: DayOfWeek): String = d.getDisplayName(TextStyle.NARROW, Locale.getDefault())
+ fun dayFull(d: DayOfWeek): String = d.getDisplayName(TextStyle.FULL, Locale.getDefault())
+
+ /** mm:ss or h:mm:ss */
+ fun duration(ms: Long): String {
+ val s = abs(ms) / 1000
+ val h = s / 3600; val m = s / 60 % 60; val sec = s % 60
+ return if (h > 0) String.format(Locale.getDefault(), "%d:%02d:%02d", h, m, sec) else String.format(Locale.getDefault(), "%d:%02d", m, sec)
+ }
+
+ /** mm:ss.cc, with hours when needed */
+ fun stopwatch(ms: Long): String {
+ val cs = ms / 10 % 100; val s = ms / 1000
+ val h = s / 3600; val m = s / 60 % 60; val sec = s % 60
+ return if (h > 0) String.format(Locale.getDefault(), "%d:%02d:%02d.%02d", h, m, sec, cs)
+ else String.format(Locale.getDefault(), "%02d:%02d.%02d", m, sec, cs)
+ }
+
+ /** "in 7 Std. 12 Min." */
+ fun until(res: Resources, fireAt: Long, now: Long = System.currentTimeMillis()): String {
+ val mins = ((fireAt - now + 59_999) / 60_000).coerceAtLeast(0)
+ val d = mins / 1440; val h = mins / 60 % 24; val m = mins % 60
+ return when {
+ d > 0 -> res.getString(R.string.in_days_hours, d, h)
+ h > 0 -> res.getString(R.string.in_hours_minutes, h, m)
+ else -> res.getString(R.string.in_minutes, m)
+ }
+ }
+
+ /** "Heute", "Morgen", or the weekday, for the next ring. */
+ fun ringDay(res: Resources, fireAt: Long): String {
+ val d = Instant.ofEpochMilli(fireAt).atZone(ZoneId.systemDefault()).toLocalDate()
+ val today = LocalDate.now()
+ return when (d) {
+ today -> res.getString(R.string.today)
+ today.plusDays(1) -> res.getString(R.string.tomorrow)
+ else -> d.format(DateTimeFormatter.ofPattern("EEE, d. MMM", Locale.getDefault()))
+ }
+ }
+
+ /** Repeat summary: "Jeden Tag", "Werktags", "Mo, Mi, Fr". */
+ fun days(res: Resources, days: Int, weekStart: Int): String = when (days) {
+ 0 -> ""
+ 0b1111111 -> res.getString(R.string.every_day)
+ 0b0011111 -> res.getString(R.string.weekdays)
+ 0b1100000 -> res.getString(R.string.weekends)
+ else -> orderedDays(weekStart).filter { days and (1 shl (it.value - 1)) != 0 }.joinToString(", ") { dayShort(it) }
+ }
+
+ fun orderedDays(weekStart: Int): List = (0 until 7).map { DayOfWeek.of((weekStart - 1 + it) % 7 + 1) }
+
+ /** "+7 Std." / "−1:30 Std." relative to local time */
+ fun offset(res: Resources, zone: ZoneId, now: Long = System.currentTimeMillis()): String {
+ val i = Instant.ofEpochMilli(now)
+ val diff = Duration.ofSeconds((zone.rules.getOffset(i).totalSeconds - ZoneId.systemDefault().rules.getOffset(i).totalSeconds).toLong())
+ if (diff.isZero) return res.getString(R.string.same_time)
+ val sign = if (diff.isNegative) "−" else "+"
+ val a = diff.abs()
+ val txt = if (a.toMinutesPart() == 0) "${a.toHours()}" else String.format(Locale.getDefault(), "%d:%02d", a.toHours(), a.toMinutesPart())
+ val there = i.atZone(zone).toLocalDate(); val here = i.atZone(ZoneId.systemDefault()).toLocalDate()
+ val day = when { there.isAfter(here) -> res.getString(R.string.tomorrow) + " · "; there.isBefore(here) -> res.getString(R.string.yesterday) + " · "; else -> "" }
+ return day + res.getString(R.string.hours_offset, sign + txt)
+ }
+}
diff --git a/uhr/app/src/main/java/app/uhr/ui/components/SettingsKit.kt b/uhr/app/src/main/java/app/uhr/ui/components/SettingsKit.kt
new file mode 100644
index 0000000..9be6544
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/ui/components/SettingsKit.kt
@@ -0,0 +1,161 @@
+package app.uhr.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Check
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import app.uhr.ui.theme.WSpace
+import app.uhr.ui.theme.WType
+
+/*
+ * Settings in the style of current Pixel system settings: grouped tiles with large outer
+ * corners and small inner ones, 2dp gaps, tonal icon circles, values as summaries,
+ * choices in dialogs.
+ */
+
+private val Outer = 24.dp
+private val Inner = 4.dp
+
+private fun tileShape(index: Int, count: Int): Shape = when {
+ count == 1 -> RoundedCornerShape(Outer)
+ index == 0 -> RoundedCornerShape(topStart = Outer, topEnd = Outer, bottomStart = Inner, bottomEnd = Inner)
+ index == count - 1 -> RoundedCornerShape(topStart = Inner, topEnd = Inner, bottomStart = Outer, bottomEnd = Outer)
+ else -> RoundedCornerShape(Inner)
+}
+
+class GroupScope internal constructor() {
+ internal val items = mutableListOf<@Composable (Shape) -> Unit>()
+ fun item(content: @Composable (Shape) -> Unit) { items += content }
+}
+
+@Composable
+fun SettingsGroup(title: String? = null, modifier: Modifier = Modifier, content: GroupScope.() -> Unit) {
+ val scope = GroupScope().apply(content)
+ Column(modifier.padding(horizontal = WSpace.s16)) {
+ if (title != null) {
+ Text(
+ title, style = WType.label.copy(fontSize = WType.bodySm.fontSize), color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = WSpace.s16, top = WSpace.s24, bottom = WSpace.s8).semantics { heading() },
+ )
+ } else Spacer(Modifier.padding(top = WSpace.s16))
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ scope.items.forEachIndexed { i, item -> item(tileShape(i, scope.items.size)) }
+ }
+ }
+}
+
+@Composable
+private fun Tile(shape: Shape, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
+ Box(
+ modifier.fillMaxWidth().clip(shape).background(MaterialTheme.colorScheme.surfaceContainerLowest).heightIn(min = 72.dp),
+ contentAlignment = Alignment.CenterStart,
+ ) { content() }
+}
+
+@Composable
+private fun RowContent(icon: ImageVector?, title: String, summary: String?, trailing: (@Composable () -> Unit)?) {
+ Row(Modifier.fillMaxWidth().padding(horizontal = WSpace.s16, vertical = WSpace.s16), verticalAlignment = Alignment.CenterVertically) {
+ if (icon != null) {
+ Box(Modifier.size(40.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center) {
+ Icon(icon, null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(22.dp))
+ }
+ Spacer(Modifier.width(WSpace.s16))
+ }
+ Column(Modifier.weight(1f)) {
+ Text(title, style = WType.body, color = MaterialTheme.colorScheme.onSurface)
+ if (summary != null) Text(summary, style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ if (trailing != null) { Spacer(Modifier.width(WSpace.s12)); trailing() }
+ }
+}
+
+@Composable
+fun SettingsItem(shape: Shape, title: String, summary: String? = null, icon: ImageVector? = null, onClick: (() -> Unit)? = null, trailing: (@Composable () -> Unit)? = null) {
+ Tile(shape, if (onClick != null) Modifier.clip(shape).clickable(onClick = onClick) else Modifier) { RowContent(icon, title, summary, trailing) }
+}
+
+@Composable
+fun SettingsSwitch(shape: Shape, title: String, checked: Boolean, onChange: (Boolean) -> Unit, summary: String? = null, icon: ImageVector? = null) {
+ Tile(shape, Modifier.clip(shape).toggleable(checked, role = Role.Switch, onValueChange = onChange)) {
+ RowContent(icon, title, summary) {
+ Switch(
+ checked, onCheckedChange = null,
+ thumbContent = if (checked) { { Icon(Icons.Rounded.Check, null, Modifier.size(SwitchDefaults.IconSize)) } } else null,
+ )
+ }
+ }
+}
+
+/** A tile whose value is picked from a list in a dialog, like Pixel's radio settings. */
+@Composable
+fun SettingsChoice(shape: Shape, title: String, options: List, selected: T, label: @Composable (T) -> String, onPick: (T) -> Unit, icon: ImageVector? = null) {
+ var open by remember { mutableStateOf(false) }
+ SettingsItem(shape, title, label(selected), icon, onClick = { open = true })
+ if (open) {
+ AlertDialog(
+ onDismissRequest = { open = false },
+ title = { Text(title) },
+ text = {
+ Column(Modifier.selectableGroup()) {
+ options.forEach { o ->
+ Row(
+ Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp))
+ .selectable(o == selected, role = Role.RadioButton) { onPick(o); open = false }
+ .padding(vertical = WSpace.s12, horizontal = WSpace.s4),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(o == selected, onClick = null)
+ Spacer(Modifier.width(WSpace.s16))
+ Text(label(o), style = WType.body)
+ }
+ }
+ }
+ },
+ confirmButton = { TextButton(onClick = { open = false }) { Text(androidx.compose.ui.res.stringResource(android.R.string.cancel)) } },
+ )
+ }
+}
+
+/** Free content inside a group tile (swatches, sliders). */
+@Composable
+fun SettingsCustom(shape: Shape, content: @Composable ColumnScope.() -> Unit) {
+ Tile(shape) { Column(Modifier.fillMaxWidth().padding(WSpace.s16), content = content) }
+}
diff --git a/uhr/app/src/main/java/app/uhr/ui/screens/AlarmScreen.kt b/uhr/app/src/main/java/app/uhr/ui/screens/AlarmScreen.kt
new file mode 100644
index 0000000..3bb6c8f
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/ui/screens/AlarmScreen.kt
@@ -0,0 +1,378 @@
+package app.uhr.ui.screens
+
+import android.content.Intent
+import android.media.RingtoneManager
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.AlarmOff
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.outlined.Label
+import androidx.compose.material.icons.outlined.NotificationsActive
+import androidx.compose.material.icons.outlined.PauseCircle
+import androidx.compose.material.icons.outlined.Vibration
+import androidx.compose.material.icons.rounded.Check
+import androidx.compose.material.icons.rounded.ExpandLess
+import androidx.compose.material.icons.rounded.ExpandMore
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.DatePicker
+import androidx.compose.material3.DatePickerDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.material3.TimeInput
+import androidx.compose.material3.TimePicker
+import androidx.compose.material3.rememberDatePickerState
+import androidx.compose.material3.rememberTimePickerState
+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.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+import app.uhr.R
+import app.uhr.data.Alarm
+import app.uhr.data.AlarmWithInstance
+import app.uhr.data.InstanceState
+import app.uhr.data.Settings
+import app.uhr.ui.components.Fmt
+import app.uhr.ui.theme.WRadius
+import app.uhr.ui.theme.WSpace
+import app.uhr.ui.theme.WType
+import app.uhr.ui.theme.tabular
+import kotlinx.coroutines.launch
+import java.time.Instant
+import java.time.LocalDate
+import java.time.ZoneId
+import java.time.ZoneOffset
+
+/** Actions the alarm screen needs; MainActivity passes the repository, tests pass fakes. */
+interface AlarmActions {
+ fun save(alarm: Alarm)
+ fun delete(alarm: Alarm)
+ fun restore(alarm: Alarm)
+ fun dismissNext(alarm: Alarm)
+}
+
+/** S01 + S02: list of alarms, each card expands to edit in place. */
+@Composable
+fun AlarmScreen(
+ alarms: List,
+ settings: Settings,
+ actions: AlarmActions,
+ snackbar: SnackbarHostState,
+ contentPadding: PaddingValues,
+ initiallyExpanded: Long? = null,
+) {
+ var expanded by rememberSaveable { mutableStateOf(initiallyExpanded) }
+ val scope = rememberCoroutineScope()
+ val res = LocalContext.current.resources
+ if (alarms.isEmpty()) {
+ Column(Modifier.fillMaxSize().padding(contentPadding), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
+ Icon(Icons.Outlined.AlarmOff, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(Modifier.height(WSpace.s12))
+ Text(stringResource(R.string.no_alarms), style = WType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ return
+ }
+ val bedtime = alarms.filter { it.alarm.isBedtimeWake }
+ val others = alarms.filter { !it.alarm.isBedtimeWake }
+ LazyColumn(
+ Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(start = WSpace.s16, end = WSpace.s16, top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding() + 120.dp),
+ verticalArrangement = Arrangement.spacedBy(WSpace.s8),
+ ) {
+ if (bedtime.isNotEmpty()) item(key = "h-bed") { SectionHeader(stringResource(R.string.wake_up)) }
+ items(bedtime, key = { it.alarm.id }) { a ->
+ AlarmCard(a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions, onDelete = null)
+ }
+ if (bedtime.isNotEmpty() && others.isNotEmpty()) item(key = "h-other") { SectionHeader(stringResource(R.string.other_alarms)) }
+ items(others, key = { it.alarm.id }) { a ->
+ AlarmCard(
+ a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions,
+ onDelete = {
+ actions.delete(a.alarm)
+ expanded = null
+ scope.launch {
+ val r = snackbar.showSnackbar(res.getString(R.string.alarm_deleted), res.getString(R.string.undo), duration = SnackbarDuration.Short)
+ if (r == SnackbarResult.ActionPerformed) actions.restore(a.alarm)
+ }
+ },
+ )
+ }
+ }
+}
+
+@Composable
+private fun SectionHeader(text: String) =
+ Text(text, style = WType.label, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = WSpace.s8, top = WSpace.s12, bottom = WSpace.s4))
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun AlarmCard(
+ item: AlarmWithInstance,
+ settings: Settings,
+ expanded: Boolean,
+ onToggleExpand: () -> Unit,
+ actions: AlarmActions,
+ onDelete: (() -> Unit)?,
+) {
+ val a = item.alarm
+ val inst = item.instance
+ val res = LocalContext.current.resources
+ val scheme = MaterialTheme.colorScheme
+ var pickTime by remember { mutableStateOf(false) }
+ var pickSound by remember { mutableStateOf(false) }
+ var pickPause by remember { mutableStateOf(false) }
+ val timeColor = if (a.enabled) scheme.onSurface else scheme.onSurfaceVariant.copy(alpha = .7f)
+ val status = when {
+ !a.enabled -> if (a.repeats) Fmt.days(res, a.days, settings.weekStart) else stringResource(R.string.not_scheduled)
+ inst?.state == InstanceState.SNOOZED -> stringResource(R.string.snoozed_until, Fmt.clock(inst.fireAt))
+ a.skipUntil != null && inst != null -> stringResource(R.string.paused_until, Fmt.ringDay(res, inst.fireAt))
+ a.repeats -> Fmt.days(res, a.days, settings.weekStart)
+ inst != null -> Fmt.ringDay(res, inst.fireAt)
+ else -> ""
+ }
+ Column(
+ Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow)
+ .clickable(onClick = onToggleExpand).padding(WSpace.s20).animateContentSize(spring(dampingRatio = .85f, stiffness = 380f))
+ .semantics(mergeDescendants = !expanded) {
+ contentDescription = listOf(a.label, Fmt.time(a.hour, a.minute), status).filter { it.isNotBlank() }.joinToString(", ")
+ stateDescription = res.getString(if (a.enabled) R.string.on else R.string.off)
+ },
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (expanded) {
+ LabelField(a.label) { actions.save(a.copy(label = it)) }
+ } else {
+ Text(a.label, style = WType.label, color = scheme.onSurfaceVariant, modifier = Modifier.weight(1f))
+ }
+ Box(Modifier.size(28.dp).clip(CircleShape).background(scheme.surfaceContainerHigh), contentAlignment = Alignment.Center) {
+ Icon(if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, null, Modifier.size(20.dp), tint = scheme.onSurface)
+ }
+ }
+ Spacer(Modifier.height(WSpace.s8))
+ Row(Modifier.clip(RoundedCornerShape(WRadius.sm)).clickable { pickTime = true }, verticalAlignment = Alignment.Bottom) {
+ Text(Fmt.time(a.hour, a.minute), style = tabular(WType.alarmTime), color = timeColor)
+ Fmt.ampm(a.hour)?.let { Text(" $it", style = WType.alarmAmpm, color = timeColor, modifier = Modifier.padding(bottom = 6.dp)) }
+ }
+ Spacer(Modifier.height(WSpace.s8))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(status, style = WType.bodySm, color = scheme.onSurface, modifier = Modifier.weight(1f))
+ Switch(a.enabled, onCheckedChange = { actions.save(a.copy(enabled = it, skipUntil = null)) })
+ }
+ AnimatedVisibility(expanded) {
+ Column {
+ Spacer(Modifier.height(WSpace.s12))
+ DayChips(a, settings.weekStart) { actions.save(it) }
+ Spacer(Modifier.height(WSpace.s8))
+ if (a.repeats) OptionRow(Icons.Outlined.PauseCircle, if (a.skipUntil != null) stringResource(R.string.paused_until_date, a.skipUntil.toString()) else stringResource(R.string.pause_alarm)) { pickPause = true }
+ if (inst != null && a.enabled && inst.state == InstanceState.SCHEDULED) OptionRow(Icons.Outlined.AlarmOff, stringResource(R.string.skip_next)) { actions.dismissNext(a) }
+ OptionRow(Icons.Outlined.NotificationsActive, soundName(a.ringtoneUri)) { pickSound = true }
+ OptionRow(Icons.Outlined.Vibration, stringResource(R.string.vibrate), trailing = { if (a.vibrate) Icon(Icons.Rounded.Check, null, tint = scheme.primary) }) {
+ actions.save(a.copy(vibrate = !a.vibrate))
+ }
+ if (onDelete != null) OptionRow(Icons.Outlined.Delete, stringResource(R.string.delete), onClick = onDelete)
+ }
+ }
+ }
+ if (pickTime) TimeDialog(a.hour, a.minute, onDismiss = { pickTime = false }) { h, m -> pickTime = false; actions.save(a.copy(hour = h, minute = m, enabled = true)) }
+ if (pickSound) SoundDialog(a.ringtoneUri, onDismiss = { pickSound = false }) { uri -> pickSound = false; actions.save(a.copy(ringtoneUri = uri)) }
+ if (pickPause) {
+ val state = rememberDatePickerState(
+ initialSelectedDateMillis = (a.skipUntil ?: LocalDate.now().plusDays(1)).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(),
+ )
+ DatePickerDialog(
+ onDismissRequest = { pickPause = false },
+ confirmButton = {
+ TextButton(onClick = {
+ pickPause = false
+ state.selectedDateMillis?.let { actions.save(a.copy(skipUntil = Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate(), enabled = true)) }
+ }) { Text(stringResource(android.R.string.ok)) }
+ },
+ dismissButton = {
+ TextButton(onClick = { pickPause = false; actions.save(a.copy(skipUntil = null)) }) { Text(stringResource(R.string.resume)) }
+ },
+ ) { DatePicker(state, title = { Text(stringResource(R.string.pause_until), Modifier.padding(start = 24.dp, top = 16.dp)) }) }
+ }
+}
+
+@Composable
+private fun androidx.compose.foundation.layout.RowScope.LabelField(value: String, onDone: (String) -> Unit) {
+ var text by remember(value) { mutableStateOf(value) }
+ TextField(
+ text, { text = it }, Modifier.weight(1f),
+ placeholder = { Text(stringResource(R.string.add_label)) },
+ leadingIcon = { Icon(Icons.Outlined.Label, null) },
+ singleLine = true, textStyle = WType.body,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { onDone(text.trim()) }),
+ colors = TextFieldDefaults.colors(
+ focusedContainerColor = androidx.compose.ui.graphics.Color.Transparent, unfocusedContainerColor = androidx.compose.ui.graphics.Color.Transparent,
+ unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
+ ),
+ )
+ androidx.compose.runtime.DisposableEffect(Unit) { onDispose { if (text.trim() != value) onDone(text.trim()) } }
+}
+
+@Composable
+private fun DayChips(a: Alarm, weekStart: Int, onChange: (Alarm) -> Unit) {
+ val scheme = MaterialTheme.colorScheme
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Fmt.orderedDays(weekStart).forEach { d ->
+ val on = a.ringsOn(d)
+ Box(
+ Modifier.size(38.dp).clip(CircleShape)
+ .background(if (on) scheme.primary else androidx.compose.ui.graphics.Color.Transparent)
+ .border(1.dp, if (on) scheme.primary else scheme.outline, CircleShape)
+ .clickable(role = Role.Checkbox) { onChange(a.withDay(d, !on)) }
+ .semantics { contentDescription = Fmt.dayFull(d) },
+ contentAlignment = Alignment.Center,
+ ) { Text(Fmt.dayNarrow(d), style = WType.label, color = if (on) scheme.onPrimary else scheme.onSurface) }
+ }
+ }
+}
+
+@Composable
+private fun OptionRow(icon: ImageVector, text: String, trailing: @Composable (() -> Unit)? = null, onClick: () -> Unit) {
+ Row(
+ Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.sm)).clickable(onClick = onClick).padding(vertical = WSpace.s12, horizontal = WSpace.s4),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(Modifier.width(WSpace.s16))
+ Text(text, style = WType.bodySm, modifier = Modifier.weight(1f))
+ trailing?.invoke()
+ }
+}
+
+@Composable
+fun soundName(uri: String?): String {
+ val context = LocalContext.current
+ return when (uri) {
+ null -> stringResource(R.string.sound_default)
+ Alarm.SILENT -> stringResource(R.string.sound_silent)
+ else -> remember(uri) {
+ runCatching { RingtoneManager.getRingtone(context, Uri.parse(uri))?.getTitle(context) }.getOrNull()
+ ?: Uri.parse(uri).lastPathSegment ?: uri
+ }
+ }
+}
+
+/** S03: M3 time picker with dial/keyboard switch. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TimeDialog(hour: Int, minute: Int, onDismiss: () -> Unit, onPick: (Int, Int) -> Unit) {
+ val state = rememberTimePickerState(hour, minute, Fmt.is24h)
+ var keyboard by remember { mutableStateOf(false) }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.pick_time)) },
+ text = {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ if (keyboard) TimeInput(state) else TimePicker(state)
+ TextButton(onClick = { keyboard = !keyboard }) { Text(stringResource(if (keyboard) R.string.use_dial else R.string.use_keyboard)) }
+ }
+ },
+ confirmButton = { TextButton(onClick = { onPick(state.hour, state.minute) }) { Text(stringResource(android.R.string.ok)) } },
+ dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
+ )
+}
+
+/** S04: system alarm tones, the bundled one, silence, or an own file (persisted read permission). */
+@Composable
+fun SoundDialog(current: String?, onDismiss: () -> Unit, onPick: (String?) -> Unit) {
+ val context = LocalContext.current
+ val tones = remember {
+ val list = mutableListOf>()
+ runCatching {
+ val rm = RingtoneManager(context).apply { setType(RingtoneManager.TYPE_ALARM) }
+ val c = rm.cursor
+ while (c.moveToNext()) list += rm.getRingtoneUri(c.position).toString() to c.getString(RingtoneManager.TITLE_COLUMN_INDEX)
+ }
+ list
+ }
+ var preview by remember { mutableStateOf(null) }
+ androidx.compose.runtime.DisposableEffect(Unit) { onDispose { preview?.stop() } }
+ fun play(uri: String?) {
+ preview?.stop()
+ val u = when (uri) { null -> Uri.parse("android.resource://${context.packageName}/${R.raw.alarm_default}"); Alarm.SILENT -> null; else -> Uri.parse(uri) }
+ preview = u?.let { RingtoneManager.getRingtone(context, it) }?.also { it.play() }
+ }
+ val file = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ if (uri != null) {
+ runCatching { context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) }
+ onPick(uri.toString())
+ }
+ }
+ var selected by remember { mutableStateOf(current) }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.alarm_sound)) },
+ text = {
+ LazyColumn(Modifier.height(420.dp)) {
+ item { TextButton(onClick = { file.launch(arrayOf("audio/*")) }) { Text(stringResource(R.string.add_own_sound)) } }
+ val rows = listOf>(null to context.getString(R.string.sound_default), Alarm.SILENT to context.getString(R.string.sound_silent)) + tones
+ items(rows) { (uri, name) ->
+ Row(
+ Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).clickable { selected = uri; play(uri) }.padding(vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(selected == uri, onClick = null)
+ Spacer(Modifier.width(WSpace.s12))
+ Text(name, style = WType.body)
+ }
+ }
+ }
+ },
+ confirmButton = { TextButton(onClick = { onPick(selected) }) { Text(stringResource(android.R.string.ok)) } },
+ dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
+ )
+}
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
new file mode 100644
index 0000000..53b78ef
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/ui/screens/BedtimeScreen.kt
@@ -0,0 +1,124 @@
+package app.uhr.ui.screens
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+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.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import app.uhr.R
+import app.uhr.data.Alarm
+import app.uhr.data.Bedtime
+import app.uhr.data.Settings
+import app.uhr.ui.components.Fmt
+import app.uhr.ui.components.SettingsChoice
+import app.uhr.ui.components.SettingsGroup
+import app.uhr.ui.components.SettingsSwitch
+import app.uhr.ui.theme.WRadius
+import app.uhr.ui.theme.WSpace
+import app.uhr.ui.theme.WType
+import app.uhr.ui.theme.tabular
+
+/** S10: bedtime and wake-up, with the expected sleep; the wake-up becomes the "Aufwachen" alarm. */
+@Composable
+fun BedtimeScreen(b: Bedtime, settings: Settings, onSave: (Bedtime) -> Unit, contentPadding: PaddingValues) {
+ var pickBed by remember { mutableStateOf(false) }
+ var pickWake by remember { mutableStateOf(false) }
+ val scheme = MaterialTheme.colorScheme
+ if (!b.enabled && b == Bedtime()) {
+ Column(Modifier.fillMaxSize().padding(contentPadding).padding(WSpace.s32), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
+ Icon(Icons.Outlined.Bedtime, null, Modifier.size(72.dp), tint = scheme.primary)
+ Spacer(Modifier.height(WSpace.s16))
+ Text(stringResource(R.string.bedtime_intro_title), style = WType.headline, textAlign = TextAlign.Center)
+ Spacer(Modifier.height(WSpace.s8))
+ Text(stringResource(R.string.bedtime_intro), style = WType.body, color = scheme.onSurfaceVariant, textAlign = TextAlign.Center)
+ Spacer(Modifier.height(WSpace.s24))
+ Button(onClick = { onSave(b.copy(enabled = true)) }) { Text(stringResource(R.string.get_started)) }
+ }
+ return
+ }
+ val sleepMin = ((b.wakeHour * 60 + b.wakeMinute) - (b.bedHour * 60 + b.bedMinute) + 1440) % 1440
+ Column(Modifier.fillMaxSize().padding(contentPadding).verticalScroll(rememberScrollState())) {
+ Row(Modifier.fillMaxWidth().padding(horizontal = WSpace.s16, vertical = WSpace.s8), horizontalArrangement = Arrangement.spacedBy(WSpace.s8)) {
+ TimeTile(Icons.Outlined.Bedtime, stringResource(R.string.bedtime), Fmt.time(b.bedHour, b.bedMinute), Modifier.weight(1f)) { pickBed = true }
+ TimeTile(Icons.Outlined.WbSunny, stringResource(R.string.wake_up), Fmt.time(b.wakeHour, b.wakeMinute), Modifier.weight(1f)) { pickWake = true }
+ }
+ Text(
+ stringResource(R.string.sleep_duration, sleepMin / 60, sleepMin % 60), style = WType.body, color = scheme.onSurfaceVariant,
+ modifier = Modifier.fillMaxWidth().padding(WSpace.s8), textAlign = TextAlign.Center,
+ )
+ Row(Modifier.fillMaxWidth().padding(horizontal = WSpace.s24, vertical = WSpace.s8), horizontalArrangement = Arrangement.SpaceBetween) {
+ Fmt.orderedDays(settings.weekStart).forEach { d ->
+ val on = b.days and (1 shl (d.value - 1)) != 0
+ Box(
+ Modifier.size(38.dp).clip(CircleShape).background(if (on) scheme.primary else Color.Transparent)
+ .border(1.dp, if (on) scheme.primary else scheme.outline, CircleShape)
+ .clickable(role = Role.Checkbox) { onSave(b.copy(days = if (on) b.days and (1 shl (d.value - 1)).inv() else b.days or (1 shl (d.value - 1)))) }
+ .semantics { contentDescription = Fmt.dayFull(d) },
+ contentAlignment = Alignment.Center,
+ ) { Text(Fmt.dayNarrow(d), style = WType.label, color = if (on) scheme.onPrimary else scheme.onSurface) }
+ }
+ }
+ SettingsGroup {
+ item { SettingsSwitch(it, stringResource(R.string.bedtime_schedule), b.enabled, { v -> onSave(b.copy(enabled = v)) }, icon = Icons.Outlined.Bedtime) }
+ item {
+ val options = listOf(-1, 0, 15, 30, 60)
+ SettingsChoice(it, stringResource(R.string.bedtime_reminder), options, b.reminderMin,
+ { 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)
+ }
+ }
+ 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)) }
+ if (pickWake) TimeDialog(b.wakeHour, b.wakeMinute, { pickWake = false }) { h, m -> pickWake = false; onSave(b.copy(wakeHour = h, wakeMinute = m)) }
+}
+
+@Composable
+private fun TimeTile(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, time: String, modifier: Modifier, onClick: () -> Unit) {
+ Column(
+ modifier.clip(RoundedCornerShape(WRadius.card)).background(MaterialTheme.colorScheme.surfaceContainerLow).clickable(onClick = onClick).padding(WSpace.s20),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(icon, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
+ Text(" $label", style = WType.label, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Spacer(Modifier.height(WSpace.s8))
+ Text(time, style = tabular(WType.timerTime))
+ }
+}
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
new file mode 100644
index 0000000..6552617
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/ui/screens/ClockScreen.kt
@@ -0,0 +1,160 @@
+package app.uhr.ui.screens
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Alarm
+import androidx.compose.material.icons.rounded.Delete
+import androidx.compose.material.icons.rounded.Search
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.SwipeToDismissBox
+import androidx.compose.material3.SwipeToDismissBoxValue
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberSwipeToDismissBoxState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import app.uhr.R
+import app.uhr.data.AlarmInstance
+import app.uhr.data.City
+import app.uhr.data.ClockStyle
+import app.uhr.data.Settings
+import app.uhr.data.ZoneHit
+import app.uhr.data.Zones
+import app.uhr.ui.components.AnalogClock
+import app.uhr.ui.components.Fmt
+import app.uhr.ui.theme.WRadius
+import app.uhr.ui.theme.WSpace
+import app.uhr.ui.theme.WType
+import app.uhr.ui.theme.tabular
+import kotlinx.coroutines.delay
+import java.time.Instant
+import java.time.ZoneId
+
+/** S06: local time and the world clock. [nowOverride] pins the time for screenshot tests. */
+@Composable
+fun ClockScreen(
+ settings: Settings,
+ cities: List,
+ next: AlarmInstance?,
+ contentPadding: PaddingValues,
+ onDeleteCity: (City) -> Unit,
+ nowOverride: Long? = null,
+) {
+ val tick = remember { mutableLongStateOf(nowOverride ?: System.currentTimeMillis()) }
+ if (nowOverride == null) LaunchedEffect(settings.showSeconds) {
+ while (true) { tick.longValue = System.currentTimeMillis(); delay(if (settings.showSeconds) 1000 else 1000 - tick.longValue % 1000) }
+ }
+ val now = tick.longValue
+ val res = LocalContext.current.resources
+ val scheme = MaterialTheme.colorScheme
+ LazyColumn(
+ Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(start = WSpace.s16, end = WSpace.s16, top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding() + 120.dp),
+ verticalArrangement = Arrangement.spacedBy(WSpace.s8),
+ ) {
+ item(key = "header") {
+ Column(Modifier.fillMaxWidth().padding(vertical = WSpace.s24), horizontalAlignment = Alignment.CenterHorizontally) {
+ if (settings.clockStyle == ClockStyle.ANALOG) {
+ AnalogClock(
+ Instant.ofEpochMilli(now).atZone(ZoneId.systemDefault()), 240.dp,
+ face = scheme.surfaceContainerLow, hourHand = scheme.primary, minuteHand = scheme.onSurface,
+ secondHand = if (settings.showSeconds) scheme.tertiary else null,
+ )
+ } else {
+ Text(Fmt.clock(now, seconds = settings.showSeconds), style = tabular(WType.clockBig), color = scheme.onSurface)
+ }
+ Spacer(Modifier.height(WSpace.s8))
+ Text(Fmt.longDate(now), style = WType.body, color = scheme.onSurfaceVariant)
+ if (next != null) Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = WSpace.s4)) {
+ Icon(Icons.Outlined.Alarm, null, Modifier.size(16.dp), tint = scheme.onSurfaceVariant)
+ Text(" " + Fmt.ringDay(res, next.fireAt) + ", " + Fmt.clock(next.fireAt), style = WType.bodySm, color = scheme.onSurfaceVariant)
+ }
+ }
+ }
+ items(cities, key = { it.id }) { city -> CityRow(city, now, settings) { onDeleteCity(city) } }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun CityRow(city: City, now: Long, settings: Settings, onDelete: () -> Unit) {
+ val res = LocalContext.current.resources
+ val zone = remember(city.zoneId) { ZoneId.of(city.zoneId) }
+ val state = rememberSwipeToDismissBoxState()
+ LaunchedEffect(state.currentValue) { if (state.currentValue != SwipeToDismissBoxValue.Settled) onDelete() }
+ SwipeToDismissBox(
+ state,
+ backgroundContent = {
+ Box(Modifier.fillMaxSize().clip(RoundedCornerShape(WRadius.card)).background(MaterialTheme.colorScheme.errorContainer).padding(horizontal = 24.dp), contentAlignment = Alignment.CenterEnd) {
+ Icon(Icons.Rounded.Delete, stringResource(R.string.delete), tint = MaterialTheme.colorScheme.onErrorContainer)
+ }
+ },
+ ) {
+ Row(
+ Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(MaterialTheme.colorScheme.surfaceContainerLow).padding(WSpace.s20),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(city.name, style = WType.title)
+ Text(Fmt.offset(res, zone, now), style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Text(Fmt.clock(now, zone, settings.showSeconds), style = tabular(WType.timerTime))
+ }
+ }
+}
+
+/** S07: offline city search. */
+@Composable
+fun AddCityDialog(onDismiss: () -> Unit, onPick: (ZoneHit) -> Unit) {
+ var q by remember { mutableStateOf("") }
+ val hits = remember(q) { Zones.search(q) }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.add_city)) },
+ text = {
+ Column {
+ OutlinedTextField(q, { q = it }, Modifier.fillMaxWidth(), placeholder = { Text(stringResource(R.string.search_city)) },
+ leadingIcon = { Icon(Icons.Rounded.Search, null) }, singleLine = true, shape = RoundedCornerShape(WRadius.pill))
+ LazyColumn(Modifier.height(360.dp).padding(top = WSpace.s8)) {
+ items(hits, key = { it.zoneId }) { h ->
+ Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).clickable { onPick(h) }.padding(vertical = 10.dp, horizontal = 4.dp)) {
+ Text(h.city, style = WType.body)
+ Text(h.zoneId.replace('_', ' '), style = WType.labelSm, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ }
+ }
+ },
+ confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
+ )
+}
diff --git a/uhr/app/src/main/java/app/uhr/ui/screens/SettingsScreen.kt b/uhr/app/src/main/java/app/uhr/ui/screens/SettingsScreen.kt
new file mode 100644
index 0000000..3ad321e
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/ui/screens/SettingsScreen.kt
@@ -0,0 +1,92 @@
+package app.uhr.ui.screens
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material.icons.outlined.AccessTime
+import androidx.compose.material.icons.outlined.CalendarMonth
+import androidx.compose.material.icons.outlined.DarkMode
+import androidx.compose.material.icons.outlined.Snooze
+import androidx.compose.material.icons.outlined.TrendingUp
+import androidx.compose.material.icons.outlined.Vibration
+import androidx.compose.material.icons.outlined.VolumeOff
+import androidx.compose.material.icons.outlined.VolumeUp
+import androidx.compose.material.icons.outlined.Watch
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LargeTopAppBar
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberTopAppBarState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringResource
+import app.uhr.R
+import app.uhr.data.ClockStyle
+import app.uhr.data.Settings
+import app.uhr.data.ThemeMode
+import app.uhr.data.VolumeButtons
+import app.uhr.ui.components.Fmt
+import app.uhr.ui.components.SettingsChoice
+import app.uhr.ui.components.SettingsGroup
+import app.uhr.ui.components.SettingsSwitch
+import java.time.DayOfWeek
+
+/** S13 */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen(s: Settings, onChange: ((Settings) -> Settings) -> Unit, onBack: () -> Unit) {
+ val bar = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
+ val minutes = @Composable { m: Int -> if (m == 0) stringResource(R.string.never) else stringResource(R.string.n_minutes, m) }
+ Scaffold(
+ modifier = Modifier.nestedScroll(bar.nestedScrollConnection),
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ topBar = {
+ LargeTopAppBar(
+ title = { Text(stringResource(R.string.settings)) },
+ navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.back)) } },
+ colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainer, scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer),
+ scrollBehavior = bar,
+ )
+ },
+ ) { pad ->
+ Column(Modifier.padding(pad).fillMaxSize().verticalScroll(rememberScrollState()).navigationBarsPadding()) {
+ SettingsGroup(stringResource(R.string.alarms)) {
+ item { SettingsChoice(it, stringResource(R.string.silence_after), listOf(1, 5, 10, 15, 20, 25, 0), s.silenceAfterMin, { m -> minutes(m) }, { v -> onChange { x -> x.copy(silenceAfterMin = v) } }, Icons.Outlined.VolumeOff) }
+ item { SettingsChoice(it, stringResource(R.string.snooze_length), (1..30).toList(), s.snoozeMin, { m -> stringResource(R.string.n_minutes, m) }, { v -> onChange { x -> x.copy(snoozeMin = v) } }, Icons.Outlined.Snooze) }
+ item { SettingsChoice(it, stringResource(R.string.alarm_volume), listOf(20, 40, 60, 80, 100), s.alarmVolume, { v -> "$v %" }, { v -> onChange { x -> x.copy(alarmVolume = v) } }, Icons.Outlined.VolumeUp) }
+ item { SettingsChoice(it, stringResource(R.string.gradual_volume), listOf(0, 5, 10, 15, 20, 30, 60), s.gradualVolumeS, { v -> if (v == 0) stringResource(R.string.off) else stringResource(R.string.n_seconds, v) }, { v -> onChange { x -> x.copy(gradualVolumeS = v) } }, Icons.Outlined.TrendingUp) }
+ item {
+ val labels = mapOf(VolumeButtons.VOLUME to R.string.vb_volume, VolumeButtons.SNOOZE to R.string.snooze, VolumeButtons.STOP to R.string.stop, VolumeButtons.NOTHING to R.string.vb_nothing)
+ SettingsChoice(it, stringResource(R.string.volume_buttons), VolumeButtons.entries, s.volumeButtons, { v -> stringResource(labels.getValue(v)) }, { v -> onChange { x -> x.copy(volumeButtons = v) } }, Icons.Outlined.VolumeUp)
+ }
+ item { SettingsChoice(it, stringResource(R.string.week_start), listOf(1, 6, 7), s.weekStart, { d -> Fmt.dayFull(DayOfWeek.of(d)) }, { v -> onChange { x -> x.copy(weekStart = v) } }, Icons.Outlined.CalendarMonth) }
+ }
+ SettingsGroup(stringResource(R.string.timer)) {
+ item { SettingsSwitch(it, stringResource(R.string.timer_vibrate), s.timerVibrate, { v -> onChange { x -> x.copy(timerVibrate = v) } }, icon = Icons.Outlined.Vibration) }
+ }
+ SettingsGroup(stringResource(R.string.clock)) {
+ item { SettingsChoice(it, stringResource(R.string.style), ClockStyle.entries, s.clockStyle, { v -> stringResource(if (v == ClockStyle.DIGITAL) R.string.digital else R.string.analog) }, { v -> onChange { x -> x.copy(clockStyle = v) } }, Icons.Outlined.Watch) }
+ item { SettingsSwitch(it, stringResource(R.string.show_seconds), s.showSeconds, { v -> onChange { x -> x.copy(showSeconds = v) } }, icon = Icons.Outlined.AccessTime) }
+ item {
+ val labels = mapOf(ThemeMode.SYSTEM to R.string.theme_system, ThemeMode.LIGHT to R.string.theme_light, ThemeMode.DARK to R.string.theme_dark)
+ SettingsChoice(it, stringResource(R.string.theme), ThemeMode.entries, s.theme, { v -> stringResource(labels.getValue(v)) }, { v -> onChange { x -> x.copy(theme = v) } }, Icons.Outlined.DarkMode)
+ }
+ }
+ Spacer(Modifier.height(32.dp()))
+ }
+ }
+}
+
+private fun Int.dp() = androidx.compose.ui.unit.Dp(this.toFloat())
diff --git a/uhr/app/src/main/java/app/uhr/ui/screens/StopwatchScreen.kt b/uhr/app/src/main/java/app/uhr/ui/screens/StopwatchScreen.kt
new file mode 100644
index 0000000..57b79ee
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/ui/screens/StopwatchScreen.kt
@@ -0,0 +1,74 @@
+package app.uhr.ui.screens
+
+import android.os.SystemClock
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.withFrameMillis
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import app.uhr.R
+import app.uhr.data.Stopwatch
+import app.uhr.data.StopwatchState
+import app.uhr.ui.components.Fmt
+import app.uhr.ui.theme.WSpace
+import app.uhr.ui.theme.WType
+import app.uhr.ui.theme.tabular
+
+/** S09: big time, laps, three large pill buttons at the bottom. */
+@Composable
+fun StopwatchScreen(sw: Stopwatch, onToggle: () -> Unit, onLap: () -> Unit, onReset: () -> Unit, contentPadding: PaddingValues, nowOverride: Long? = null) {
+ val tick = remember { mutableLongStateOf(nowOverride ?: SystemClock.elapsedRealtime()) }
+ if (nowOverride == null && sw.state == StopwatchState.RUNNING) LaunchedEffect(sw) { while (true) withFrameMillis { tick.longValue = SystemClock.elapsedRealtime() } }
+ val scheme = MaterialTheme.colorScheme
+ val elapsed = sw.elapsed(tick.longValue)
+ Column(Modifier.fillMaxSize().padding(contentPadding).padding(horizontal = WSpace.s16)) {
+ Spacer(Modifier.height(WSpace.s32))
+ Text(Fmt.stopwatch(elapsed), style = tabular(WType.stopwatch), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
+ val laps = sw.laps
+ LazyColumn(Modifier.weight(1f).padding(top = WSpace.s16)) {
+ val splits = laps.mapIndexed { i, total -> total - (laps.getOrNull(i - 1) ?: 0) }
+ val fastest = splits.minOrNull(); val slowest = splits.maxOrNull()
+ itemsIndexed(laps.reversed()) { ri, total ->
+ val i = laps.size - 1 - ri
+ val split = splits[i]
+ val color = when { laps.size < 2 -> scheme.onSurface; split == fastest -> scheme.primary; split == slowest -> scheme.error; else -> scheme.onSurface }
+ Row(Modifier.fillMaxWidth().padding(vertical = WSpace.s8, horizontal = WSpace.s8)) {
+ Text(stringResource(R.string.lap_n, i + 1), style = WType.body, color = color, modifier = Modifier.weight(1f))
+ Text(Fmt.stopwatch(split), style = tabular(WType.body), color = color, modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
+ Text(Fmt.stopwatch(total), style = tabular(WType.body), color = scheme.onSurfaceVariant, modifier = Modifier.weight(1f), textAlign = TextAlign.End)
+ }
+ }
+ }
+ Column(verticalArrangement = Arrangement.spacedBy(WSpace.s8), modifier = Modifier.padding(bottom = WSpace.s16)) {
+ val running = sw.state == StopwatchState.RUNNING
+ Pill(
+ stringResource(if (running) R.string.stop else R.string.start),
+ if (running) scheme.tertiaryContainer else scheme.primaryContainer,
+ if (running) scheme.onTertiaryContainer else scheme.onPrimaryContainer,
+ height = 88, onClick = onToggle,
+ )
+ if (sw.state != StopwatchState.IDLE) {
+ Pill(stringResource(R.string.reset), scheme.surfaceContainerHigh, scheme.onSurface, height = 72, onClick = onReset)
+ if (running) Pill(stringResource(R.string.lap), scheme.surfaceContainerHigh, scheme.onSurface, height = 72, onClick = onLap)
+ }
+ }
+ }
+}
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
new file mode 100644
index 0000000..db7de23
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/ui/screens/TimerScreen.kt
@@ -0,0 +1,207 @@
+package app.uhr.ui.screens
+
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.Backspace
+import androidx.compose.material.icons.rounded.Close
+import androidx.compose.material.icons.rounded.Pause
+import androidx.compose.material.icons.rounded.PlayArrow
+import androidx.compose.material.icons.rounded.Replay
+import androidx.compose.material.icons.rounded.Stop
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import app.uhr.R
+import app.uhr.data.Timer
+import app.uhr.data.TimerState
+import app.uhr.ui.components.Fmt
+import app.uhr.ui.theme.WRadius
+import app.uhr.ui.theme.WSpace
+import app.uhr.ui.theme.WType
+import app.uhr.ui.theme.tabular
+import kotlinx.coroutines.delay
+
+interface TimerActions {
+ fun start(durationMs: Long)
+ fun pause(id: Long)
+ fun resume(id: Long)
+ fun addMinute(id: Long)
+ fun reset(id: Long)
+ fun delete(id: Long)
+ fun stopRinging()
+}
+
+/** S08: keypad when there is no timer (or after "+"), cards for running ones. */
+@Composable
+fun TimerScreen(
+ timers: List,
+ showKeypad: Boolean,
+ digits: String,
+ onDigits: (String) -> Unit,
+ actions: TimerActions,
+ contentPadding: PaddingValues,
+ nowOverride: Long? = null,
+) {
+ if (timers.isEmpty() || showKeypad) {
+ Keypad(digits, onDigits, Modifier.padding(contentPadding))
+ return
+ }
+ val tick = remember { mutableLongStateOf(nowOverride ?: System.currentTimeMillis()) }
+ if (nowOverride == null) LaunchedEffect(Unit) { while (true) { tick.longValue = System.currentTimeMillis(); delay(100) } }
+ LazyColumn(
+ Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(start = WSpace.s16, end = WSpace.s16, top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding() + 120.dp),
+ verticalArrangement = Arrangement.spacedBy(WSpace.s8),
+ ) {
+ items(timers, key = { it.id }) { TimerCard(it, tick.longValue, actions) }
+ }
+}
+
+@Composable
+private fun TimerCard(t: Timer, now: Long, actions: TimerActions) {
+ val scheme = MaterialTheme.colorScheme
+ val remaining = t.remaining(now)
+ val progress = (remaining.toFloat() / t.durationMs).coerceIn(0f, 1f)
+ val blink = rememberInfiniteTransition(label = "blink").animateFloat(1f, .3f, infiniteRepeatable(tween(600), RepeatMode.Reverse), label = "a")
+ 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))
+ IconButton(onClick = { actions.delete(t.id) }) { Icon(Icons.Rounded.Close, stringResource(R.string.delete)) }
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(Modifier.weight(1f).aspectRatio(1f).padding(WSpace.s8), contentAlignment = Alignment.Center) {
+ val track = scheme.surfaceContainerHigh
+ val ring = scheme.primary
+ Canvas(Modifier.fillMaxSize()) {
+ val w = 6.dp.toPx()
+ drawArc(track, 0f, 360f, false, style = Stroke(w))
+ drawArc(ring, -90f, 360f * progress, false, style = Stroke(w, cap = StrokeCap.Round))
+ }
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ (if (remaining < 0) "−" else "") + Fmt.duration(remaining + if (remaining > 0) 999 else 0),
+ style = tabular(WType.timerTime),
+ modifier = Modifier.alpha(if (t.state == TimerState.PAUSED || t.state == TimerState.RINGING) blink.value else 1f)
+ .semantics { contentDescription = Fmt.duration(remaining) },
+ )
+ IconButton(onClick = { actions.reset(t.id) }) { Icon(Icons.Rounded.Replay, stringResource(R.string.reset)) }
+ }
+ }
+ Spacer(Modifier.width(WSpace.s16))
+ Column(Modifier.width(120.dp), verticalArrangement = Arrangement.spacedBy(WSpace.s12)) {
+ Pill(stringResource(R.string.plus_minute), scheme.surfaceContainerHigh, scheme.onSurface) { actions.addMinute(t.id) }
+ when (t.state) {
+ TimerState.RUNNING -> PillIcon(Icons.Rounded.Pause, stringResource(R.string.pause), app.uhr.ui.theme.fabColors().first, app.uhr.ui.theme.fabColors().second) { actions.pause(t.id) }
+ TimerState.PAUSED -> PillIcon(Icons.Rounded.PlayArrow, stringResource(R.string.start), app.uhr.ui.theme.fabColors().first, app.uhr.ui.theme.fabColors().second) { actions.resume(t.id) }
+ TimerState.RINGING -> PillIcon(Icons.Rounded.Stop, stringResource(R.string.stop), scheme.tertiaryContainer, scheme.onTertiaryContainer) { actions.stopRinging() }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun Pill(text: String, bg: androidx.compose.ui.graphics.Color, fg: androidx.compose.ui.graphics.Color, modifier: Modifier = Modifier, height: Int = 56, onClick: () -> Unit) {
+ Box(modifier.fillMaxWidth().height(height.dp).clip(RoundedCornerShape(WRadius.pill)).background(bg).clickable(onClick = onClick), contentAlignment = Alignment.Center) {
+ Text(text, style = if (height >= 72) WType.title else WType.label, color = fg)
+ }
+}
+
+@Composable
+fun PillIcon(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, bg: androidx.compose.ui.graphics.Color, fg: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {
+ Box(Modifier.fillMaxWidth().height(64.dp).clip(RoundedCornerShape(WRadius.pill)).background(bg).clickable(onClick = onClick), contentAlignment = Alignment.Center) {
+ Icon(icon, label, tint = fg)
+ }
+}
+
+/** Digits fill from the right: "130" = 1 min 30 s. Max 6 digits (99h 59m 59s). */
+fun digitsToMs(d: String): Long {
+ val p = d.padStart(6, '0')
+ return ((p.substring(0, 2).toLong() * 3600) + p.substring(2, 4).toLong() * 60 + p.substring(4, 6).toLong()) * 1000
+}
+
+@Composable
+private fun Keypad(digits: String, onDigits: (String) -> Unit, modifier: Modifier) {
+ val scheme = MaterialTheme.colorScheme
+ val p = digits.padStart(6, '0')
+ Column(modifier.fillMaxSize().padding(horizontal = WSpace.s24), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
+ val active = scheme.primary; val idle = scheme.onSurfaceVariant
+ Text(
+ buildAnnotatedString {
+ listOf(p.substring(0, 2) to "h", p.substring(2, 4) to "m", p.substring(4, 6) to "s").forEachIndexed { i, (v, u) ->
+ val c = if (digits.length > 4 - i * 2) active else idle
+ withStyle(SpanStyle(color = c, fontSize = WType.clockBig.fontSize * .7f)) { append(v) }
+ withStyle(SpanStyle(color = c, fontSize = WType.title.fontSize)) { append(u + " ") }
+ }
+ },
+ style = tabular(WType.clockBig),
+ )
+ Spacer(Modifier.height(WSpace.s32))
+ val keys = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "00", "0", "⌫")
+ keys.chunked(3).forEach { row ->
+ Row(horizontalArrangement = Arrangement.spacedBy(WSpace.s16), modifier = Modifier.padding(vertical = WSpace.s8)) {
+ row.forEach { k ->
+ Box(
+ Modifier.size(80.dp).clip(CircleShape).background(if (k == "⌫") scheme.secondaryContainer else scheme.surfaceContainerHigh)
+ .clickable {
+ onDigits(
+ when (k) {
+ "⌫" -> digits.dropLast(1)
+ else -> (digits + k).trimStart('0').take(6)
+ }
+ )
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ if (k == "⌫") Icon(Icons.AutoMirrored.Rounded.Backspace, stringResource(R.string.backspace))
+ else Text(k, style = WType.headline)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/uhr/app/src/main/java/app/uhr/ui/theme/Theme.kt b/uhr/app/src/main/java/app/uhr/ui/theme/Theme.kt
new file mode 100644
index 0000000..2493bbe
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/ui/theme/Theme.kt
@@ -0,0 +1,72 @@
+package app.uhr.ui.theme
+
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Typography
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import app.uhr.data.ThemeMode
+
+private val Light = lightColorScheme(
+ primary = WColor.accent, onPrimary = WColor.onAccent,
+ primaryContainer = WColor.accentContainer, onPrimaryContainer = WColor.onAccentContainer,
+ secondaryContainer = WColor.navIndicator, onSecondaryContainer = WColor.text,
+ tertiaryContainer = WColor.stop, onTertiaryContainer = WColor.onStop,
+ background = WColor.bg, onBackground = WColor.text,
+ surface = WColor.bg, onSurface = WColor.text, onSurfaceVariant = WColor.textMuted,
+ surfaceContainer = WColor.nav, surfaceContainerLow = WColor.card, surfaceContainerHigh = WColor.cardHigh,
+ surfaceContainerLowest = WColor.surface,
+ outline = WColor.borderInput, outlineVariant = WColor.border, error = WColor.danger,
+)
+
+private val Dark = darkColorScheme(
+ primary = WColor.darkAccent, onPrimary = WColor.darkOnAccent,
+ primaryContainer = WColor.darkAccentContainer, onPrimaryContainer = WColor.darkOnAccentContainer,
+ secondaryContainer = WColor.darkNavIndicator, onSecondaryContainer = WColor.darkText,
+ tertiaryContainer = WColor.darkStop, onTertiaryContainer = WColor.darkOnStop,
+ background = WColor.darkBg, onBackground = WColor.darkText,
+ surface = WColor.darkBg, onSurface = WColor.darkText, onSurfaceVariant = WColor.darkTextMuted,
+ surfaceContainer = WColor.darkNav, surfaceContainerLow = WColor.darkCard, surfaceContainerHigh = WColor.darkCardHigh,
+ surfaceContainerLowest = WColor.darkCardHigh,
+ outline = WColor.darkBorderInput, outlineVariant = WColor.darkBorder, error = WColor.darkDanger,
+)
+
+private val AppTypography = Typography(
+ displayLarge = WType.clockBig, headlineLarge = WType.headline, titleLarge = WType.title,
+ bodyLarge = WType.body, bodyMedium = WType.bodySm, labelLarge = WType.label, labelMedium = WType.label, labelSmall = WType.labelSm,
+)
+
+/** Tabular figures so times do not jump while they tick. */
+fun tabular(style: androidx.compose.ui.text.TextStyle) = style.copy(fontFeatureSettings = "tnum")
+
+@Composable
+fun ClockTheme(mode: ThemeMode = ThemeMode.SYSTEM, content: @Composable () -> Unit) {
+ val dark = when (mode) { ThemeMode.SYSTEM -> isSystemInDarkTheme(); ThemeMode.LIGHT -> false; ThemeMode.DARK -> true }
+ val context = LocalContext.current
+ // wallpaper colours on Android 12+, like the original; our palette below that
+ val scheme = when {
+ Build.VERSION.SDK_INT >= 31 -> if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ dark -> Dark
+ else -> Light
+ }
+ MaterialTheme(colorScheme = scheme, typography = AppTypography, content = content)
+}
+
+/** Fixed palette, for screenshot tests that must not depend on the wallpaper. */
+@Composable
+fun StaticClockTheme(dark: Boolean, content: @Composable () -> Unit) =
+ MaterialTheme(colorScheme = if (dark) Dark else Light, typography = AppTypography, content = content)
+
+/** The big FAB is light with a dark icon in dark mode, tonal in light mode (as in the reference). */
+@Composable
+fun fabColors(): Pair {
+ val s = MaterialTheme.colorScheme
+ val dark = s.background.run { 0.2126f * red + 0.7152f * green + 0.0722f * blue } < .5f
+ return if (dark) s.primary to s.onPrimary else s.primaryContainer to s.onPrimaryContainer
+}
+
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
new file mode 100644
index 0000000..efa007b
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/ui/theme/Tokens.kt
@@ -0,0 +1,106 @@
+// Generated from tokens.json by gen_theme.py. Do not edit by hand.
+package app.uhr.ui.theme
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+object WColor {
+ val bg = Color(0xFFF8F9FF)
+ val surface = Color(0xFFFFFFFF)
+ val card = Color(0xFFEEF0F6)
+ val cardHigh = Color(0xFFE2E5EC)
+ val border = Color(0xFFC3C6CF)
+ val borderInput = Color(0xFF73777F)
+ val text = Color(0xFF191C20)
+ val textMuted = Color(0xFF43474E)
+ val textOff = Color(0xFF5C6068)
+ val accent = Color(0xFF2F5EA8)
+ val onAccent = Color(0xFFFFFFFF)
+ val accentContainer = Color(0xFFD6E3FF)
+ val onAccentContainer = Color(0xFF001B3E)
+ val fab = Color(0xFFA8C7FA)
+ val onFab = Color(0xFF0B2A55)
+ val chipOn = Color(0xFF2F5EA8)
+ val onChipOn = Color(0xFFFFFFFF)
+ val secondaryPill = Color(0xFFDFE2EB)
+ val onSecondaryPill = Color(0xFF191C20)
+ val stop = Color(0xFFFFD8E4)
+ val onStop = Color(0xFF4A1426)
+ val danger = Color(0xFFBA1A1A)
+ val nav = Color(0xFFECEEF4)
+ val navIndicator = Color(0xFFD6E3FF)
+ val darkBg = Color(0xFF111318)
+ val darkSurface = Color(0xFF111318)
+ val darkCard = Color(0xFF1D2024)
+ val darkCardHigh = Color(0xFF272A2F)
+ val darkBorder = Color(0xFF43474E)
+ val darkBorderInput = Color(0xFF8D9199)
+ val darkText = Color(0xFFE2E2E9)
+ val darkTextMuted = Color(0xFFC3C6CF)
+ val darkTextOff = Color(0xFF9A9EA6)
+ val darkAccent = Color(0xFFA8C7FA)
+ val darkOnAccent = Color(0xFF062E6F)
+ val darkAccentContainer = Color(0xFF244A80)
+ val darkOnAccentContainer = Color(0xFFD6E3FF)
+ val darkFab = Color(0xFFA8C7FA)
+ val darkOnFab = Color(0xFF062E6F)
+ val darkChipOn = Color(0xFFA8C7FA)
+ val darkOnChipOn = Color(0xFF062E6F)
+ val darkSecondaryPill = Color(0xFF3A3D43)
+ val darkOnSecondaryPill = Color(0xFFE2E2E9)
+ val darkStop = Color(0xFFF2B8C9)
+ val darkOnStop = Color(0xFF4A1426)
+ val darkDanger = Color(0xFFFFB4AB)
+ val darkNav = Color(0xFF1D2024)
+ val darkNavIndicator = Color(0xFF3A3F47)
+ val ringBg = Color(0xFF1E5A52)
+ val ringOn = Color(0xFFB4F1E6)
+ val ringTrack = Color(0xFF2B6E65)
+ val ringThumb = Color(0xFF3F9A7F)
+ val onRingThumb = Color(0xFFFFFFFF)
+ val timerTrack = Color(0xFF3A3D43)
+ val timerProgress = Color(0xFF7EC4A8)
+ val nightText = Color(0xFFC4433A)
+ val nightBg = Color(0xFF000000)
+}
+
+object WType {
+ val labelSm = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.W500)
+ val label = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.W500)
+ val bodySm = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.W400)
+ val body = TextStyle(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.W400)
+ val title = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.W400)
+ val headline = TextStyle(fontSize = 28.sp, lineHeight = 36.sp, fontWeight = FontWeight.W400)
+ val alarmTime = TextStyle(fontSize = 56.sp, lineHeight = 60.sp, fontWeight = FontWeight.W600)
+ val alarmAmpm = TextStyle(fontSize = 20.sp, lineHeight = 24.sp, fontWeight = FontWeight.W600)
+ val timerTime = TextStyle(fontSize = 44.sp, lineHeight = 48.sp, fontWeight = FontWeight.W400)
+ val clockBig = TextStyle(fontSize = 88.sp, lineHeight = 92.sp, fontWeight = FontWeight.W400)
+ val stopwatch = TextStyle(fontSize = 72.sp, lineHeight = 76.sp, fontWeight = FontWeight.W500)
+ val ringing = TextStyle(fontSize = 104.sp, lineHeight = 108.sp, fontWeight = FontWeight.W400)
+}
+
+object WSpace {
+ val s0 = 0.dp
+ val s2 = 2.dp
+ val s4 = 4.dp
+ val s8 = 8.dp
+ val s12 = 12.dp
+ val s16 = 16.dp
+ val s20 = 20.dp
+ val s24 = 24.dp
+ val s32 = 32.dp
+ val s48 = 48.dp
+}
+
+object WRadius {
+ val xs = 8.dp
+ val sm = 12.dp
+ val md = 16.dp
+ val lg = 24.dp
+ val card = 28.dp
+ val pill = 999.dp
+ val navIndicator = 16.dp
+}
diff --git a/uhr/app/src/main/java/app/uhr/widget/Widgets.kt b/uhr/app/src/main/java/app/uhr/widget/Widgets.kt
new file mode 100644
index 0000000..3bdc759
--- /dev/null
+++ b/uhr/app/src/main/java/app/uhr/widget/Widgets.kt
@@ -0,0 +1,149 @@
+package app.uhr.widget
+
+import android.content.Context
+import android.content.res.Configuration
+import android.os.Build
+import android.util.TypedValue
+import android.widget.RemoteViews
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.unit.dp
+import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.LocalContext
+import androidx.glance.LocalSize
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.AndroidRemoteViews
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetManager
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import androidx.glance.appwidget.SizeMode
+import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.appwidget.provideContent
+import androidx.glance.appwidget.state.updateAppWidgetState
+import androidx.glance.background
+import androidx.glance.currentState
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Box
+import androidx.glance.layout.fillMaxSize
+import app.uhr.MainActivity
+import app.uhr.R
+import app.uhr.data.Graph
+import app.uhr.ui.components.Fmt
+import app.uhr.ui.theme.WColor
+
+private val VERSION = longPreferencesKey("v")
+
+/** Widget colours: wallpaper on Android 12+, our tokens below. */
+private data class WPal(val bg: Color, val fg: Color, val accent: Color)
+
+private fun palette(context: Context): WPal {
+ val dark = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
+ if (Build.VERSION.SDK_INT >= 31) {
+ val s = if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ return WPal(s.primaryContainer, s.onPrimaryContainer, s.primary)
+ }
+ return if (dark) WPal(WColor.darkCard, WColor.darkText, WColor.darkAccent) else WPal(WColor.card, WColor.text, WColor.accent)
+}
+
+/** Glance's update() only recomposes; bumping state makes widgets read the next alarm again. */
+suspend fun updateClockWidgets(context: Context) {
+ val manager = GlanceAppWidgetManager(context)
+ for (w in listOf(DigitalClockWidget(), AnalogClockWidget(), NextAlarmWidget())) {
+ manager.getGlanceIds(w.javaClass).forEach { id ->
+ runCatching { updateAppWidgetState(context, id) { it[VERSION] = System.nanoTime() }; w.update(context, id) }
+ }
+ }
+}
+
+private suspend fun nextAlarmText(context: Context): String? {
+ Graph.init(context)
+ val i = Graph.repo.nextInstanceOnce() ?: return null
+ return Fmt.ringDay(context.resources, i.fireAt) + ", " + Fmt.clock(i.fireAt)
+}
+
+abstract class ClockWidget : GlanceAppWidget() {
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ Fmt.is24h = android.text.format.DateFormat.is24HourFormat(context)
+ val first = nextAlarmText(context)
+ provideContent {
+ val v = currentState(VERSION) ?: 0L
+ val next by produceState(first, v) { if (v != 0L) value = nextAlarmText(context) }
+ Content(next)
+ }
+ }
+
+ override suspend fun providePreview(context: Context, widgetCategory: Int) {
+ val next = nextAlarmText(context)
+ provideContent { Content(next) }
+ }
+
+ @Composable abstract fun Content(next: String?)
+}
+
+private fun RemoteViews.color(id: Int, c: Color) = setTextColor(id, c.toArgb())
+
+/** Big live time, date, next alarm. Transparent, like a home-screen clock. */
+class DigitalClockWidget : ClockWidget() {
+ override val sizeMode = SizeMode.Exact
+
+ @Composable
+ override fun Content(next: String?) {
+ val ctx = LocalContext.current
+ val size = LocalSize.current
+ val views = RemoteViews(ctx.packageName, R.layout.widget_digital).apply {
+ setTextViewTextSize(R.id.w_time, TypedValue.COMPLEX_UNIT_SP, (size.height.value * .45f).coerceIn(40f, 110f))
+ if (next != null) setTextViewText(R.id.w_next, "⏰ $next") else setTextViewText(R.id.w_next, "")
+ }
+ Box(GlanceModifier.fillMaxSize().clickable(actionStartActivity(android.content.Intent(ctx, MainActivity::class.java))), contentAlignment = Alignment.Center) {
+ AndroidRemoteViews(views)
+ }
+ }
+}
+
+/** Own analog face on a wallpaper-coloured disc. */
+class AnalogClockWidget : ClockWidget() {
+ override val sizeMode = SizeMode.Exact
+
+ @Composable
+ override fun Content(next: String?) {
+ val ctx = LocalContext.current
+ val p = palette(ctx)
+ val views = RemoteViews(ctx.packageName, R.layout.widget_analog)
+ Box(
+ GlanceModifier.fillMaxSize().cornerRadius(999.dp).background(p.bg).clickable(actionStartActivity(android.content.Intent(ctx, MainActivity::class.java))),
+ contentAlignment = Alignment.Center,
+ ) { AndroidRemoteViews(views) }
+ }
+}
+
+/** Pill with the next alarm. */
+class NextAlarmWidget : ClockWidget() {
+ override val sizeMode = SizeMode.Exact
+
+ @Composable
+ override fun Content(next: String?) {
+ val ctx = LocalContext.current
+ val p = palette(ctx)
+ val views = RemoteViews(ctx.packageName, R.layout.widget_next).apply {
+ setTextViewText(R.id.w_next_text, next ?: ctx.getString(R.string.no_alarm_set))
+ color(R.id.w_next_text, p.fg)
+ setInt(R.id.w_next_icon, "setColorFilter", p.accent.toArgb())
+ }
+ Box(
+ GlanceModifier.fillMaxSize().cornerRadius(999.dp).background(p.bg).clickable(actionStartActivity(android.content.Intent(ctx, MainActivity::class.java))),
+ contentAlignment = Alignment.Center,
+ ) { AndroidRemoteViews(views) }
+ }
+}
+
+class DigitalClockReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = DigitalClockWidget() }
+class AnalogClockReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = AnalogClockWidget() }
+class NextAlarmReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = NextAlarmWidget() }
diff --git a/uhr/app/src/main/res/drawable/analog_dial.xml b/uhr/app/src/main/res/drawable/analog_dial.xml
new file mode 100644
index 0000000..00595aa
--- /dev/null
+++ b/uhr/app/src/main/res/drawable/analog_dial.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/uhr/app/src/main/res/drawable/analog_hour.xml b/uhr/app/src/main/res/drawable/analog_hour.xml
new file mode 100644
index 0000000..bd6c072
--- /dev/null
+++ b/uhr/app/src/main/res/drawable/analog_hour.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/uhr/app/src/main/res/drawable/analog_minute.xml b/uhr/app/src/main/res/drawable/analog_minute.xml
new file mode 100644
index 0000000..478dd69
--- /dev/null
+++ b/uhr/app/src/main/res/drawable/analog_minute.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/uhr/app/src/main/res/drawable/ic_launcher_background.xml b/uhr/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..2f2c4d1
--- /dev/null
+++ b/uhr/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1 @@
+
diff --git a/uhr/app/src/main/res/drawable/ic_launcher_foreground.xml b/uhr/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..0e4b5f1
--- /dev/null
+++ b/uhr/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/uhr/app/src/main/res/drawable/ic_launcher_monochrome.xml b/uhr/app/src/main/res/drawable/ic_launcher_monochrome.xml
new file mode 100644
index 0000000..67b0bb6
--- /dev/null
+++ b/uhr/app/src/main/res/drawable/ic_launcher_monochrome.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/uhr/app/src/main/res/drawable/ic_stat_alarm.xml b/uhr/app/src/main/res/drawable/ic_stat_alarm.xml
new file mode 100644
index 0000000..383ee62
--- /dev/null
+++ b/uhr/app/src/main/res/drawable/ic_stat_alarm.xml
@@ -0,0 +1 @@
+
diff --git a/uhr/app/src/main/res/drawable/ic_stat_bedtime.xml b/uhr/app/src/main/res/drawable/ic_stat_bedtime.xml
new file mode 100644
index 0000000..cff4f36
--- /dev/null
+++ b/uhr/app/src/main/res/drawable/ic_stat_bedtime.xml
@@ -0,0 +1 @@
+
diff --git a/uhr/app/src/main/res/drawable/ic_stat_timer.xml b/uhr/app/src/main/res/drawable/ic_stat_timer.xml
new file mode 100644
index 0000000..92a5c23
--- /dev/null
+++ b/uhr/app/src/main/res/drawable/ic_stat_timer.xml
@@ -0,0 +1 @@
+
diff --git a/uhr/app/src/main/res/layout/widget_analog.xml b/uhr/app/src/main/res/layout/widget_analog.xml
new file mode 100644
index 0000000..fb362d9
--- /dev/null
+++ b/uhr/app/src/main/res/layout/widget_analog.xml
@@ -0,0 +1,4 @@
+
+
diff --git a/uhr/app/src/main/res/layout/widget_digital.xml b/uhr/app/src/main/res/layout/widget_digital.xml
new file mode 100644
index 0000000..c121f1a
--- /dev/null
+++ b/uhr/app/src/main/res/layout/widget_digital.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/uhr/app/src/main/res/layout/widget_next.xml b/uhr/app/src/main/res/layout/widget_next.xml
new file mode 100644
index 0000000..7cf8438
--- /dev/null
+++ b/uhr/app/src/main/res/layout/widget_next.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/uhr/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/uhr/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..80faec8
--- /dev/null
+++ b/uhr/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/uhr/app/src/main/res/raw/alarm_default.wav b/uhr/app/src/main/res/raw/alarm_default.wav
new file mode 100644
index 0000000..7999c9b
Binary files /dev/null and b/uhr/app/src/main/res/raw/alarm_default.wav differ
diff --git a/uhr/app/src/main/res/values/strings.xml b/uhr/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..d3abe53
--- /dev/null
+++ b/uhr/app/src/main/res/values/strings.xml
@@ -0,0 +1,130 @@
+
+
+ Wecker
+ Uhr
+ Timer
+ Stoppuhr
+ Schlafen
+ Weitere Optionen
+ Einstellungen
+ Zurück
+ Hinzufügen
+ Löschen
+ Rückgängig
+ An
+ Aus
+ Nie
+ Später
+ Einstellungen öffnen
+
+ Wecker
+ Wecker
+ Noch keine Wecker. Tippe auf +, um einen zu stellen.
+ Aufwachen
+ Weitere
+ Wecker gelöscht
+ Klingelt %1$s
+ in %1$d T. %2$d Std.
+ in %1$d Std. %2$d Min.
+ in %1$d Min.
+ Heute
+ Morgen
+ Gestern
+ Jeden Tag
+ Werktags
+ Am Wochenende
+ Nicht geplant
+ Schlummert bis %1$s
+ Pausiert · nächstes Mal %1$s
+ Pausiert bis %1$s
+ Wecker pausieren
+ Pausieren bis einschließlich
+ Nicht pausieren
+ Nächsten Weckruf überspringen
+ Name hinzufügen
+ Vibration
+ Weckton
+ Standard (Morgenklang)
+ Ohne Ton
+ + Eigene Audiodatei wählen
+ Uhrzeit wählen
+ Zifferblatt
+ Tastatur
+
+ Schlummern
+ Stopp
+ Jetzt beenden
+
+ Uhr
+ Stadt hinzufügen
+ Stadt oder Zeitzone
+ Gleiche Zeit
+ %1$s Std.
+
+ Timer
+ Timer · %1$s
+ Timer abgelaufen
+ +1:00
+ Pause
+ Pausiert
+ Start
+ Zurücksetzen
+ Löschen
+ %1$d Timer
+
+ Stoppuhr
+ Runde
+ Runde %1$d
+
+ Schlafenszeit
+ Ein fester Schlafrhythmus
+ Lege fest, wann du schlafen gehst und aufstehst. Du wirst rechtzeitig erinnert, und der Wecker zum Aufwachen wird automatisch gestellt.
+ Los geht\'s
+ %1$d Std. %2$d Min. Schlaf
+ Zeitplan aktiv
+ Erinnerung
+ Zur Schlafenszeit
+ %1$d Min. vorher
+
+ Automatisch stumm nach
+ Schlummerdauer
+ Lautstärke
+ Langsam lauter werden
+ Lautstärketasten beim Klingeln
+ Lautstärke ändern
+ Nichts tun
+ Woche beginnt am
+ Timer vibriert
+ Darstellung
+ Digital
+ Analog
+ Sekunden anzeigen
+ Design
+ System
+ Hell
+ Dunkel
+ %1$d Minuten
+ %1$d Sekunden
+
+ Klingeln
+ Anstehende Wecker
+ Verpasste Wecker
+ Laufende Timer und Stoppuhr
+ Schlafenszeit
+ Wecker um %1$s
+ Schlummert bis %1$s
+ Wecker verpasst
+ Bald ist Schlafenszeit
+ Leg das Handy weg und komm zur Ruhe.
+
+ Wecker im Vollbild
+ Damit der Wecker auch über dem Sperrbildschirm erscheint, braucht die App die Erlaubnis für Vollbild-Benachrichtigungen. Ohne sie klingelt er trotzdem, aber nur als Benachrichtigung.
+
+ Kein Wecker gestellt
+ Uhrzeit
+ Große Uhrzeit, Datum und nächster Wecker
+ Analoge Uhr
+ Zifferblatt in deinen Farben
+ Nächster Wecker
+ Wann der nächste Wecker klingelt
+
diff --git a/uhr/app/src/main/res/xml/widget_analog.xml b/uhr/app/src/main/res/xml/widget_analog.xml
new file mode 100644
index 0000000..025f850
--- /dev/null
+++ b/uhr/app/src/main/res/xml/widget_analog.xml
@@ -0,0 +1,8 @@
+
+
diff --git a/uhr/app/src/main/res/xml/widget_digital.xml b/uhr/app/src/main/res/xml/widget_digital.xml
new file mode 100644
index 0000000..da9d359
--- /dev/null
+++ b/uhr/app/src/main/res/xml/widget_digital.xml
@@ -0,0 +1,8 @@
+
+
diff --git a/uhr/app/src/main/res/xml/widget_next.xml b/uhr/app/src/main/res/xml/widget_next.xml
new file mode 100644
index 0000000..c76e1f8
--- /dev/null
+++ b/uhr/app/src/main/res/xml/widget_next.xml
@@ -0,0 +1,8 @@
+
+
diff --git a/uhr/app/src/test/java/app/uhr/NextRingTest.kt b/uhr/app/src/test/java/app/uhr/NextRingTest.kt
new file mode 100644
index 0000000..38e5821
--- /dev/null
+++ b/uhr/app/src/test/java/app/uhr/NextRingTest.kt
@@ -0,0 +1,44 @@
+package app.uhr
+
+import app.uhr.data.Alarm
+import app.uhr.data.nextRing
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.time.LocalDate
+import java.time.ZoneId
+import java.time.ZonedDateTime
+
+class NextRingTest {
+ private val berlin = ZoneId.of("Europe/Berlin")
+ private fun at(y: Int, m: Int, d: Int, h: Int, min: Int) = ZonedDateTime.of(y, m, d, h, min, 0, 0, berlin)
+
+ @Test fun onceLaterToday() = assertEquals(at(2026, 10, 16, 7, 0), nextRing(Alarm(hour = 7, minute = 0), at(2026, 10, 16, 6, 0)))
+ @Test fun onceTomorrowWhenPassed() = assertEquals(at(2026, 10, 17, 7, 0), nextRing(Alarm(hour = 7, minute = 0), at(2026, 10, 16, 7, 0)))
+
+ @Test fun weekdaysSkipWeekend() {
+ // Friday 2026-10-16 08:00 -> Monday
+ assertEquals(at(2026, 10, 19, 7, 0), nextRing(Alarm(hour = 7, minute = 0, days = Alarm.WEEKDAYS), at(2026, 10, 16, 8, 0)))
+ }
+
+ @Test fun pausedUntilDate() {
+ val a = Alarm(hour = 7, minute = 0, days = Alarm.EVERY_DAY, skipUntil = LocalDate.of(2026, 10, 20))
+ assertEquals(at(2026, 10, 21, 7, 0), nextRing(a, at(2026, 10, 16, 8, 0)))
+ }
+
+ @Test fun springForwardGapRingsAfterGap() {
+ // 2026-03-29 02:30 does not exist in Berlin
+ val r = nextRing(Alarm(hour = 2, minute = 30), at(2026, 3, 29, 1, 0))
+ assertEquals(3, r.hour); assertEquals(30, r.minute)
+ }
+
+ @Test fun fallBackKeepsWallClock() {
+ val r = nextRing(Alarm(hour = 7, minute = 0), at(2026, 10, 25, 1, 0))
+ assertEquals(at(2026, 10, 25, 7, 0), r)
+ }
+
+ @Test fun zoneChangeKeepsWallClock() {
+ val tokyo = ZonedDateTime.of(2026, 10, 16, 6, 0, 0, 0, ZoneId.of("Asia/Tokyo"))
+ val r = nextRing(Alarm(hour = 7, minute = 0), tokyo)
+ assertEquals(7, r.hour); assertEquals(ZoneId.of("Asia/Tokyo"), r.zone)
+ }
+}
diff --git a/uhr/app/src/test/java/app/uhr/ScreenshotTest.kt b/uhr/app/src/test/java/app/uhr/ScreenshotTest.kt
new file mode 100644
index 0000000..9a9768c
--- /dev/null
+++ b/uhr/app/src/test/java/app/uhr/ScreenshotTest.kt
@@ -0,0 +1,138 @@
+package app.uhr
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Alarm
+import androidx.compose.material.icons.outlined.Bedtime
+import androidx.compose.material.icons.outlined.HourglassEmpty
+import androidx.compose.material.icons.outlined.Schedule
+import androidx.compose.material.icons.outlined.Timer
+import androidx.compose.material.icons.rounded.Add
+import androidx.compose.material.icons.rounded.MoreVert
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FabPosition
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onRoot
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.ui.unit.dp
+import app.uhr.alarm.Ring
+import app.uhr.alarm.RingingScreen
+import app.uhr.data.Alarm
+import app.uhr.data.AlarmInstance
+import app.uhr.data.AlarmWithInstance
+import app.uhr.data.Bedtime
+import app.uhr.data.City
+import app.uhr.data.Settings
+import app.uhr.data.Stopwatch
+import app.uhr.data.StopwatchState
+import app.uhr.data.Timer
+import app.uhr.data.TimerState
+import app.uhr.data.nextRing
+import app.uhr.ui.components.Fmt
+import app.uhr.ui.screens.AlarmActions
+import app.uhr.ui.screens.AlarmScreen
+import app.uhr.ui.screens.BedtimeScreen
+import app.uhr.ui.screens.ClockScreen
+import app.uhr.ui.screens.SettingsScreen
+import app.uhr.ui.screens.StopwatchScreen
+import app.uhr.ui.screens.TimerActions
+import app.uhr.ui.screens.TimerScreen
+import app.uhr.ui.theme.StaticClockTheme
+import com.github.takahirom.roborazzi.captureRoboImage
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+import java.time.ZonedDateTime
+
+@RunWith(RobolectricTestRunner::class)
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+@Config(sdk = [35], qualifiers = "de-rDE-w412dp-h915dp-xxhdpi", application = android.app.Application::class)
+class ScreenshotTest {
+ @get:Rule val compose = createComposeRule()
+
+ private val now = System.currentTimeMillis()
+ private fun inst(a: Alarm) = AlarmInstance(a.id, nextRing(a, ZonedDateTime.now()).toInstant().toEpochMilli())
+ private val alarms = listOf(
+ Alarm(id = 1, hour = 6, minute = 30, days = Alarm.WEEKDAYS, label = "Arbeit"),
+ Alarm(id = 2, hour = 8, minute = 0, days = 0b1100000, label = "Wochenende"),
+ Alarm(id = 3, hour = 14, minute = 45, label = "Paket abholen", enabled = false),
+ ).map { AlarmWithInstance(it, if (it.enabled) inst(it) else null) }
+ private val noAlarms = object : AlarmActions {
+ override fun save(alarm: Alarm) {}; override fun delete(alarm: Alarm) {}
+ override fun restore(alarm: Alarm) {}; override fun dismissNext(alarm: Alarm) {}
+ }
+ private val noTimers = object : TimerActions {
+ override fun start(durationMs: Long) {}; override fun pause(id: Long) {}; override fun resume(id: Long) {}
+ override fun addMinute(id: Long) {}; override fun reset(id: Long) {}; override fun delete(id: Long) {}; override fun stopRinging() {}
+ }
+
+ private fun shot(name: String, dark: Boolean, content: @Composable () -> Unit) {
+ Fmt.is24h = true
+ compose.setContent { StaticClockTheme(dark) { content() } }
+ compose.onRoot().captureRoboImage("../../replica/uhr/clone-screens/$name.png")
+ }
+
+ /** Same frame as MainActivity: title, bottom navigation, large centred FAB. */
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Composable
+ private fun Frame(tab: Int, fab: Boolean = true, body: @Composable (PaddingValues) -> Unit) {
+ val tabs = listOf("Wecker" to Icons.Outlined.Alarm, "Uhr" to Icons.Outlined.Schedule, "Timer" to Icons.Outlined.HourglassEmpty, "Stoppuhr" to Icons.Outlined.Timer, "Schlafen" to Icons.Outlined.Bedtime)
+ Scaffold(
+ topBar = { TopAppBar({ Text(tabs[tab].first) }, actions = { Icon(Icons.Rounded.MoreVert, null) }, colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface)) },
+ bottomBar = { NavigationBar { tabs.forEachIndexed { i, t -> NavigationBarItem(i == tab, {}, { Icon(t.second, null) }, label = { Text(t.first) }) } } },
+ floatingActionButton = {
+ if (fab) FloatingActionButton({}, shape = CircleShape, modifier = Modifier.size(96.dp), containerColor = app.uhr.ui.theme.fabColors().first, contentColor = app.uhr.ui.theme.fabColors().second) { Icon(Icons.Rounded.Add, null, Modifier.size(36.dp)) }
+ },
+ floatingActionButtonPosition = FabPosition.Center,
+ ) { body(it) }
+ }
+
+ @Test fun s01_alarms_dark() = shot("S01-alarms-dark", true) { Frame(0) { AlarmScreen(alarms, Settings(), noAlarms, SnackbarHostState(), it) } }
+ @Test fun s01_alarms_light() = shot("S01-alarms-light", false) { Frame(0) { AlarmScreen(alarms, Settings(), noAlarms, SnackbarHostState(), it) } }
+ @Test fun s01_empty() = shot("S01-empty", true) { Frame(0) { AlarmScreen(emptyList(), Settings(), noAlarms, SnackbarHostState(), it) } }
+ @Test fun s02_expanded() = shot("S02-expanded-dark", true) { Frame(0) { AlarmScreen(alarms, Settings(), noAlarms, SnackbarHostState(), it, initiallyExpanded = 1) } }
+
+ @Test fun s06_clock() = shot("S06-clock-dark", true) {
+ val cities = listOf(City(1, "America/New_York", "New York"), City(2, "Asia/Tokyo", "Tokio"), City(3, "Europe/London", "London"))
+ Frame(1) { ClockScreen(Settings(), cities, alarms[0].instance, it, {}, nowOverride = now) }
+ }
+ @Test fun s06_analog() = shot("S06-analog-light", false) {
+ Frame(1) { ClockScreen(Settings(clockStyle = app.uhr.data.ClockStyle.ANALOG, showSeconds = true), listOf(City(1, "Asia/Tokyo", "Tokio")), null, it, {}, nowOverride = now) }
+ }
+
+ @Test fun s08_keypad() = shot("S08-keypad-dark", true) { Frame(2) { TimerScreen(emptyList(), true, "530", {}, noTimers, it) } }
+ @Test fun s08_timers() = shot("S08-timers-dark", true) {
+ val timers = listOf(
+ Timer(1, "", 330_000, 0, now + 285_000, TimerState.RUNNING),
+ Timer(2, "Pizza", 3_600_000, 3_585_000, null, TimerState.PAUSED),
+ )
+ Frame(2) { TimerScreen(timers, false, "", {}, noTimers, it, nowOverride = now) }
+ }
+
+ @Test fun s09_stopwatch() = shot("S09-stopwatch-dark", true) {
+ val sw = Stopwatch(StopwatchState.RUNNING, startedAt = 0, accumulatedMs = 0, laps = listOf(15_980, 31_200, 49_870))
+ Frame(3, fab = false) { StopwatchScreen(sw, {}, {}, {}, it, nowOverride = 63_420) }
+ }
+
+ @Test fun s10_bedtime() = shot("S10-bedtime-dark", true) { Frame(4, fab = false) { BedtimeScreen(Bedtime(enabled = true, bedHour = 22, bedMinute = 45, wakeHour = 6, wakeMinute = 30), Settings(), {}, it) } }
+ @Test fun s10_intro() = shot("S10-intro-light", false) { Frame(4, fab = false) { BedtimeScreen(Bedtime(), Settings(), {}, it) } }
+ @Test fun s13_settings() = shot("S13-settings-dark", true) { SettingsScreen(Settings(), {}, {}) }
+ @Test fun s11_ringing() = shot("S11-ringing", true) { RingingScreen(Ring.AlarmRing(Alarm(hour = 6, minute = 30, label = "Arbeit")), {}, {}) }
+}
diff --git a/uhr/build.gradle.kts b/uhr/build.gradle.kts
new file mode 100644
index 0000000..6b3c874
--- /dev/null
+++ b/uhr/build.gradle.kts
@@ -0,0 +1,7 @@
+plugins {
+ id("com.android.application") version "8.13.0" apply false
+ id("org.jetbrains.kotlin.android") version "2.2.20" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
+ id("com.google.devtools.ksp") version "2.2.20-2.0.3" apply false
+ id("io.github.takahirom.roborazzi") version "1.46.1" apply false
+}
diff --git a/uhr/gradle.properties b/uhr/gradle.properties
new file mode 100644
index 0000000..f97184b
--- /dev/null
+++ b/uhr/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
diff --git a/uhr/gradle/wrapper/gradle-wrapper.jar b/uhr/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..1b33c55
Binary files /dev/null and b/uhr/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/uhr/gradle/wrapper/gradle-wrapper.properties b/uhr/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..d4081da
--- /dev/null
+++ b/uhr/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/uhr/gradlew b/uhr/gradlew
new file mode 100755
index 0000000..23d15a9
--- /dev/null
+++ b/uhr/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/uhr/gradlew.bat b/uhr/gradlew.bat
new file mode 100644
index 0000000..5eed7ee
--- /dev/null
+++ b/uhr/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/uhr/settings.gradle.kts b/uhr/settings.gradle.kts
new file mode 100644
index 0000000..22fd10c
--- /dev/null
+++ b/uhr/settings.gradle.kts
@@ -0,0 +1,16 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+rootProject.name = "Uhr"
+include(":app")