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")