build(uhr): alarms, ringing, timer, stopwatch, world clock, bedtime, settings, widgets

- exact alarms via setAlarmClock, rescheduled on boot/time/zone change and app update
- foreground ringing service: alarm-stream audio with gradual volume, vibration,
  full-screen screen with slide to snooze/stop, silence-after, missed notification
- timers and stopwatch keep timestamps only; live countdown/chronometer notifications
- world clock offline from the time zone database (no network permission at all)
- own alarm tone, own analog face and launcher icon
- next-ring logic unit-tested incl. DST gaps and zone changes; screenshot tests

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-17 15:16:50 +02:00
co-authored by Claude Opus 5.5
parent 887299a3a7
commit bc4cd8daf1
75 changed files with 4545 additions and 57 deletions
+26
View File
@@ -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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

+57 -57
View File
@@ -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
1 feature area priority original clone notes
2 Alarm list with on/off switch and next-ring text alarm must yes no yes
3 Add alarm with time picker (dial and keyboard) alarm must yes no yes
4 Expandable alarm card (edit in place) alarm must yes no yes
5 Repeat days chips alarm must yes no yes
6 Alarm label alarm must yes no yes
7 Delete alarm with undo alarm must yes no yes
8 Vibrate toggle alarm must yes no yes
9 Alarm sound picker with preview (system ringtones) alarm must yes no yes
10 Own audio file as alarm sound alarm should yes no partial picker works; file loss falls back to default; not device-tested
11 Pause alarm until date alarm should yes no yes
12 Skip next occurrence (dismiss early) alarm should yes no yes from notification
13 Exact scheduling that survives reboot time change and Doze alarm must yes no yes AlarmManager.setAlarmClock
14 Full-screen ringing screen over lock screen ringing must yes no yes
15 Slide control snooze/stop ringing must yes no yes
16 Snooze length setting settings must yes no yes
17 Silence after setting settings must yes no yes
18 Gradually increase volume settings should yes no yes
19 Alarm volume setting settings should yes no yes
20 Volume buttons behaviour (snooze/stop/volume/none) settings should yes no yes
21 Start week on settings could yes no yes
22 Upcoming alarm notification with dismiss now notifications should yes no yes
23 Missed alarm notification notifications should yes no yes
24 Snoozed notification with dismiss notifications must yes no yes
25 World clock: local time and date clock must yes no yes
26 World clock: add city via search clock must yes no yes
27 World clock: reorder and swipe to delete clock should yes no partial swipe to delete only
28 Analog or digital clock style clock should yes no yes
29 Show seconds clock could yes no yes
30 Automatic home clock when travelling clock could yes no
31 Timer keypad h m s timer must yes no yes
32 Run several timers at once timer must yes no yes
33 Timer ring progress +1:00 pause reset delete timer must yes no yes
34 Timer labels timer could yes no partial stored and shown; no UI to edit yet
35 Timer runs in background with notification timer must yes no yes
36 Timer ringing screen timer must yes no yes
37 Timer sound and vibrate settings settings should yes no partial vibrate setting only; sound uses default
38 Stopwatch start stop reset stopwatch must yes no yes
39 Stopwatch laps stopwatch must yes no yes
40 Stopwatch running notification stopwatch should yes no yes
41 Bedtime schedule with wake-up alarm bedtime should yes no yes
42 Bedtime reminder notification bedtime should yes no yes
43 Sunrise alarm (brightening screen) bedtime could yes no
44 Sleep sounds with timer bedtime could yes no own CC0 sounds
45 Screensaver / bedside clock with night mode display could yes no DreamService
46 Digital clock widget widgets must yes no yes
47 Analog clock widget widgets should yes no yes
48 World clock widget widgets could yes no
49 Next alarm widget widgets should yes no yes
50 Widget style options (theme colours opacity) widgets should yes no like Wetter
51 Theme system/light/dark and dynamic colour display must yes no yes
52 Expressive design (bottom bar pill cards shapes) display must yes no yes
53 Streaming alarm sounds (YouTube Music Spotify Calm) alarm could yes skip partner accounts
54 Google Assistant Routine alarm could yes skip Google service
55 Bedtime mode greyscale and DND bedtime could yes skip Digital Wellbeing privileges
56 Sleep tracking cough and snore bedtime could yes skip system privileges + Pixel hardware
57 Calendar upcoming events card bedtime could yes skip Google Calendar
+6
View File
@@ -0,0 +1,6 @@
.gradle/
build/
local.properties
.idea/
*.iml
.kotlin/
+97
View File
@@ -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")) }
View File
+289
View File
@@ -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')"
]
}
}
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity android:name=".DebugAlarmActivity" android:exported="true" android:theme="@android:style/Theme.Translucent.NoTitleBar" />
</application>
</manifest>
@@ -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()
}
}
+64
View File
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- no INTERNET, no location: nothing leaves the device -->
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:name=".UhrApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.NoActionBar">
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".alarm.RingingActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleInstance"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:taskAffinity="" />
<service android:name=".alarm.RingingService" android:exported="false" android:foregroundServiceType="mediaPlayback" />
<receiver android:name=".alarm.AlarmReceiver" android:exported="false" android:directBootAware="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<receiver android:name=".widget.DigitalClockReceiver" android:exported="true" android:label="@string/widget_digital_name">
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_digital" />
</receiver>
<receiver android:name=".widget.AnalogClockReceiver" android:exported="true" android:label="@string/widget_analog_name">
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_analog" />
</receiver>
<receiver android:name=".widget.NextAlarmReceiver" android:exported="true" android:label="@string/widget_next_name">
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_next" />
</receiver>
</application>
</manifest>
@@ -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)) } },
)
}
}
+13
View File
@@ -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)
}
}
@@ -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)
}
}
@@ -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<Timer>) {
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()
}
@@ -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)) }
}
}
@@ -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<Ring?>(null)
val current: MutableStateFlow<Ring?> get() = _current
val ringing: StateFlow<Ring?> 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),
)
}
}
@@ -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
}
}
@@ -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<List<AlarmWithInstance>> = dao.alarms()
val nextInstance: Flow<AlarmInstance?> = 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<List<Timer>> = 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<Stopwatch> = 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<List<City>> = 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<Long>) = dao.reorderCities(ids)
// --- bedtime ------------------------------------------------------------------------------
val bedtime: Flow<Bedtime> = 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()
}
+81
View File
@@ -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<List<AlarmWithInstance>>
@Query("SELECT * FROM alarms") suspend fun alarmsOnce(): List<Alarm>
@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<AlarmInstance>
@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<AlarmInstance?>
@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<List<Timer>>
@Query("SELECT * FROM timers ORDER BY created_at") suspend fun timersOnce(): List<Timer>
@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<List<City>>
@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<Long>) = 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()
}
}
@@ -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))
}
}
@@ -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,
)
@@ -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")
}
+158
View File
@@ -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<Long> = 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<Settings> = 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<Stopwatch> = 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<Bedtime> = 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 <reified E : Enum<E>> String?.enumOr(default: E): E =
this?.let { runCatching { enumValueOf<E>(it) }.getOrNull() } ?: default
@@ -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<ZoneHit> 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<ZoneHit> {
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)
}
}
@@ -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)
}
@@ -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<DayOfWeek> = (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)
}
}
@@ -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 <T> SettingsChoice(shape: Shape, title: String, options: List<T>, 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) }
}
@@ -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<AlarmWithInstance>,
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<Pair<String, String>>()
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<android.media.Ringtone?>(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<Pair<String?, String>>(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)) } },
)
}
@@ -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))
}
}
@@ -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<City>,
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)) } },
)
}
@@ -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())
@@ -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)
}
}
}
}
@@ -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<Timer>,
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)
}
}
}
}
}
}
@@ -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<androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color> {
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
}
@@ -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
}
@@ -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() }
@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="200dp" android:height="200dp" android:viewportWidth="200" android:viewportHeight="200">
<path android:fillColor="#40FFFFFF" android:pathData="M100,14m-5,0a5,5 0 1,0 10,0a5,5 0 1,0 -10,0"/>
<path android:fillColor="#40FFFFFF" android:pathData="M186,100m-5,0a5,5 0 1,0 10,0a5,5 0 1,0 -10,0"/>
<path android:fillColor="#40FFFFFF" android:pathData="M100,186m-5,0a5,5 0 1,0 10,0a5,5 0 1,0 -10,0"/>
<path android:fillColor="#40FFFFFF" android:pathData="M14,100m-5,0a5,5 0 1,0 10,0a5,5 0 1,0 -10,0"/>
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="200dp" android:height="200dp" android:viewportWidth="200" android:viewportHeight="200">
<path android:strokeColor="#FFA8C7FA" android:strokeWidth="14" android:strokeLineCap="round" android:pathData="M100,100 L100,52"/>
</vector>
@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="200dp" android:height="200dp" android:viewportWidth="200" android:viewportHeight="200">
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="8" android:strokeLineCap="round" android:pathData="M100,100 L100,30"/>
<path android:fillColor="#FFFFFFFF" android:pathData="M100,100m-9,0a9,9 0 1,0 18,0a9,9 0 1,0 -18,0"/>
</vector>
@@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"><path android:fillColor="#FF2F5EA8" android:pathData="M0,0h108v108h-108z"/></vector>
@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#FFD6E3FF" android:pathData="M82.22,54.00 L82.03,55.22 L81.50,56.41 L80.77,57.52 L79.99,58.58 L79.33,59.62 L78.91,60.67 L78.75,61.80 L78.80,63.03 L78.94,64.33 L79.02,65.67 L78.88,66.95 L78.43,68.11 L77.66,69.07 L76.61,69.84 L75.42,70.44 L74.22,70.96 L73.13,71.53 L72.23,72.23 L71.53,73.13 L70.96,74.22 L70.44,75.42 L69.84,76.61 L69.07,77.66 L68.11,78.43 L66.95,78.88 L65.67,79.02 L64.33,78.94 L63.03,78.80 L61.80,78.75 L60.67,78.91 L59.62,79.33 L58.58,79.99 L57.52,80.77 L56.41,81.50 L55.22,82.03 L54.00,82.22 L52.78,82.03 L51.59,81.50 L50.48,80.77 L49.42,79.99 L48.38,79.33 L47.33,78.91 L46.20,78.75 L44.97,78.80 L43.67,78.94 L42.33,79.02 L41.05,78.88 L39.89,78.43 L38.93,77.66 L38.16,76.61 L37.56,75.42 L37.04,74.22 L36.47,73.13 L35.77,72.23 L34.87,71.53 L33.78,70.96 L32.58,70.44 L31.39,69.84 L30.34,69.07 L29.57,68.11 L29.12,66.95 L28.98,65.67 L29.06,64.33 L29.20,63.03 L29.25,61.80 L29.09,60.67 L28.67,59.62 L28.01,58.58 L27.23,57.52 L26.50,56.41 L25.97,55.22 L25.79,54.00 L25.97,52.78 L26.50,51.59 L27.23,50.48 L28.01,49.42 L28.67,48.38 L29.09,47.33 L29.25,46.20 L29.20,44.97 L29.06,43.67 L28.98,42.33 L29.12,41.05 L29.57,39.89 L30.34,38.93 L31.39,38.16 L32.58,37.56 L33.78,37.04 L34.87,36.47 L35.77,35.77 L36.47,34.87 L37.04,33.78 L37.56,32.58 L38.16,31.39 L38.93,30.34 L39.89,29.57 L41.05,29.12 L42.33,28.98 L43.67,29.06 L44.97,29.20 L46.20,29.25 L47.33,29.09 L48.38,28.67 L49.42,28.01 L50.48,27.23 L51.59,26.50 L52.78,25.97 L54.00,25.79 L55.22,25.97 L56.41,26.50 L57.52,27.23 L58.58,28.01 L59.62,28.67 L60.67,29.09 L61.80,29.25 L63.03,29.20 L64.33,29.06 L65.67,28.98 L66.95,29.12 L68.11,29.57 L69.07,30.34 L69.84,31.39 L70.44,32.58 L70.96,33.78 L71.53,34.87 L72.23,35.77 L73.13,36.47 L74.22,37.04 L75.42,37.56 L76.61,38.16 L77.66,38.93 L78.43,39.89 L78.88,41.05 L79.02,42.33 L78.94,43.67 L78.80,44.97 L78.75,46.20 L78.91,47.33 L79.33,48.38 L79.99,49.42 L80.77,50.48 L81.50,51.59 L82.03,52.78 Z"/>
<path android:strokeColor="#FF0B2A55" android:strokeWidth="5.5" android:strokeLineCap="round" android:pathData="M54,54 L54,38"/>
<path android:strokeColor="#FF2F5EA8" android:strokeWidth="3.5" android:strokeLineCap="round" android:pathData="M54,54 L66,64"/>
<path android:fillColor="#FF0B2A55" android:pathData="M54,54m-3.5,0a3.5,3.5 0 1,0 7,0a3.5,3.5 0 1,0 -7,0"/>
</vector>
@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#FF000000" android:pathData="M82.22,54.00 L82.03,55.22 L81.50,56.41 L80.77,57.52 L79.99,58.58 L79.33,59.62 L78.91,60.67 L78.75,61.80 L78.80,63.03 L78.94,64.33 L79.02,65.67 L78.88,66.95 L78.43,68.11 L77.66,69.07 L76.61,69.84 L75.42,70.44 L74.22,70.96 L73.13,71.53 L72.23,72.23 L71.53,73.13 L70.96,74.22 L70.44,75.42 L69.84,76.61 L69.07,77.66 L68.11,78.43 L66.95,78.88 L65.67,79.02 L64.33,78.94 L63.03,78.80 L61.80,78.75 L60.67,78.91 L59.62,79.33 L58.58,79.99 L57.52,80.77 L56.41,81.50 L55.22,82.03 L54.00,82.22 L52.78,82.03 L51.59,81.50 L50.48,80.77 L49.42,79.99 L48.38,79.33 L47.33,78.91 L46.20,78.75 L44.97,78.80 L43.67,78.94 L42.33,79.02 L41.05,78.88 L39.89,78.43 L38.93,77.66 L38.16,76.61 L37.56,75.42 L37.04,74.22 L36.47,73.13 L35.77,72.23 L34.87,71.53 L33.78,70.96 L32.58,70.44 L31.39,69.84 L30.34,69.07 L29.57,68.11 L29.12,66.95 L28.98,65.67 L29.06,64.33 L29.20,63.03 L29.25,61.80 L29.09,60.67 L28.67,59.62 L28.01,58.58 L27.23,57.52 L26.50,56.41 L25.97,55.22 L25.79,54.00 L25.97,52.78 L26.50,51.59 L27.23,50.48 L28.01,49.42 L28.67,48.38 L29.09,47.33 L29.25,46.20 L29.20,44.97 L29.06,43.67 L28.98,42.33 L29.12,41.05 L29.57,39.89 L30.34,38.93 L31.39,38.16 L32.58,37.56 L33.78,37.04 L34.87,36.47 L35.77,35.77 L36.47,34.87 L37.04,33.78 L37.56,32.58 L38.16,31.39 L38.93,30.34 L39.89,29.57 L41.05,29.12 L42.33,28.98 L43.67,29.06 L44.97,29.20 L46.20,29.25 L47.33,29.09 L48.38,28.67 L49.42,28.01 L50.48,27.23 L51.59,26.50 L52.78,25.97 L54.00,25.79 L55.22,25.97 L56.41,26.50 L57.52,27.23 L58.58,28.01 L59.62,28.67 L60.67,29.09 L61.80,29.25 L63.03,29.20 L64.33,29.06 L65.67,28.98 L66.95,29.12 L68.11,29.57 L69.07,30.34 L69.84,31.39 L70.44,32.58 L70.96,33.78 L71.53,34.87 L72.23,35.77 L73.13,36.47 L74.22,37.04 L75.42,37.56 L76.61,38.16 L77.66,38.93 L78.43,39.89 L78.88,41.05 L79.02,42.33 L78.94,43.67 L78.80,44.97 L78.75,46.20 L78.91,47.33 L79.33,48.38 L79.99,49.42 L80.77,50.48 L81.50,51.59 L82.03,52.78 Z"/>
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="5.5" android:strokeLineCap="round" android:pathData="M54,54 L54,38"/>
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="3.5" android:strokeLineCap="round" android:pathData="M54,54 L66,64"/>
<path android:fillColor="#FFFFFFFF" android:pathData="M54,54m-3.5,0a3.5,3.5 0 1,0 7,0a3.5,3.5 0 1,0 -7,0"/>
</vector>
@@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFFFF" android:pathData="M12,4a8,8 0 1,0 0,16a8,8 0 1,0 0,-16zM12,18a6,6 0 1,1 0,-12a6,6 0 1,1 0,12zM12.5,8H11v5l3.9,2.4 0.8,-1.2 -3.2,-1.9zM6.2,2.3L2.3,5.6 3.6,7.1 7.4,3.9zM17.8,2.3l-1.3,1.5 3.8,3.2 1.3,-1.5z"/></vector>
@@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFFFF" android:pathData="M12.3,22h-0.1a10.3,10.3 0 0,1 -7.3,-3A10.3,10.3 0 0,1 9.1,1.8a1,1 0 0,1 1.3,1.2 8.2,8.2 0 0,0 10.6,10.6 1,1 0,0 1 1.2,1.3A10.3,10.3 0 0,1 12.3,22z"/></vector>
@@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFFFF" android:pathData="M6,2v6h0.01L6,8.01 10,12l-4,4 0.01,0.01H6V22h12v-5.99h-0.01L18,16l-4,-4 4,-3.99 -0.01,-0.01H18V2H6zM16,16.5V20H8v-3.5l4,-4 4,4zM12,11.5l-4,-4V4h8v3.5l-4,4z"/></vector>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<AnalogClock xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_height="match_parent" android:padding="8dp"
android:dial="@drawable/analog_dial" android:hand_hour="@drawable/analog_hour" android:hand_minute="@drawable/analog_minute" />
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:gravity="center">
<TextClock android:id="@+id/w_time" android:layout_width="wrap_content" android:layout_height="wrap_content"
android:format24Hour="HH:mm" android:format12Hour="h:mm" android:fontFamily="sans-serif-light"
android:textSize="80sp" android:includeFontPadding="false" android:textColor="#FFFFFFFF" android:shadowColor="#66000000" android:shadowRadius="8" android:shadowDy="2" />
<TextClock android:layout_width="wrap_content" android:layout_height="wrap_content"
android:format24Hour="EEEE, d. MMMM" android:format12Hour="EEEE, MMMM d" android:fontFamily="sans-serif-medium"
android:textSize="16sp" android:textColor="#FFFFFFFF" android:shadowColor="#66000000" android:shadowRadius="8" android:shadowDy="2" />
<TextView android:id="@+id/w_next" android:layout_width="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="2dp" android:textSize="14sp" android:textColor="#E6FFFFFF" android:shadowColor="#66000000" android:shadowRadius="8" android:shadowDy="2" />
</LinearLayout>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="horizontal"
android:gravity="center" android:paddingHorizontal="20dp">
<ImageView android:id="@+id/w_next_icon" android:layout_width="24dp" android:layout_height="24dp" android:src="@drawable/ic_stat_alarm" android:importantForAccessibility="no" />
<TextView android:id="@+id/w_next_text" android:layout_width="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="10dp" android:fontFamily="sans-serif-medium" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>
Binary file not shown.
+130
View File
@@ -0,0 +1,130 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="tab_alarm">Wecker</string>
<string name="tab_clock">Uhr</string>
<string name="tab_timer">Timer</string>
<string name="tab_stopwatch">Stoppuhr</string>
<string name="tab_bedtime">Schlafen</string>
<string name="more">Weitere Optionen</string>
<string name="settings">Einstellungen</string>
<string name="back">Zurück</string>
<string name="add">Hinzufügen</string>
<string name="delete">Löschen</string>
<string name="undo">Rückgängig</string>
<string name="on">An</string>
<string name="off">Aus</string>
<string name="never">Nie</string>
<string name="later">Später</string>
<string name="open_settings">Einstellungen öffnen</string>
<string name="alarm">Wecker</string>
<string name="alarms">Wecker</string>
<string name="no_alarms">Noch keine Wecker. Tippe auf +, um einen zu stellen.</string>
<string name="wake_up">Aufwachen</string>
<string name="other_alarms">Weitere</string>
<string name="alarm_deleted">Wecker gelöscht</string>
<string name="alarm_set_in">Klingelt %1$s</string>
<string name="in_days_hours">in %1$d T. %2$d Std.</string>
<string name="in_hours_minutes">in %1$d Std. %2$d Min.</string>
<string name="in_minutes">in %1$d Min.</string>
<string name="today">Heute</string>
<string name="tomorrow">Morgen</string>
<string name="yesterday">Gestern</string>
<string name="every_day">Jeden Tag</string>
<string name="weekdays">Werktags</string>
<string name="weekends">Am Wochenende</string>
<string name="not_scheduled">Nicht geplant</string>
<string name="snoozed_until">Schlummert bis %1$s</string>
<string name="paused_until">Pausiert · nächstes Mal %1$s</string>
<string name="paused_until_date">Pausiert bis %1$s</string>
<string name="pause_alarm">Wecker pausieren</string>
<string name="pause_until">Pausieren bis einschließlich</string>
<string name="resume">Nicht pausieren</string>
<string name="skip_next">Nächsten Weckruf überspringen</string>
<string name="add_label">Name hinzufügen</string>
<string name="vibrate">Vibration</string>
<string name="alarm_sound">Weckton</string>
<string name="sound_default">Standard (Morgenklang)</string>
<string name="sound_silent">Ohne Ton</string>
<string name="add_own_sound">+ Eigene Audiodatei wählen</string>
<string name="pick_time">Uhrzeit wählen</string>
<string name="use_dial">Zifferblatt</string>
<string name="use_keyboard">Tastatur</string>
<string name="snooze">Schlummern</string>
<string name="stop">Stopp</string>
<string name="dismiss_now">Jetzt beenden</string>
<string name="clock">Uhr</string>
<string name="add_city">Stadt hinzufügen</string>
<string name="search_city">Stadt oder Zeitzone</string>
<string name="same_time">Gleiche Zeit</string>
<string name="hours_offset">%1$s Std.</string>
<string name="timer">Timer</string>
<string name="timer_of">Timer · %1$s</string>
<string name="timer_done">Timer abgelaufen</string>
<string name="plus_minute">+1:00</string>
<string name="pause">Pause</string>
<string name="paused">Pausiert</string>
<string name="start">Start</string>
<string name="reset">Zurücksetzen</string>
<string name="backspace">Löschen</string>
<string name="n_timers">%1$d Timer</string>
<string name="stopwatch">Stoppuhr</string>
<string name="lap">Runde</string>
<string name="lap_n">Runde %1$d</string>
<string name="bedtime">Schlafenszeit</string>
<string name="bedtime_intro_title">Ein fester Schlafrhythmus</string>
<string name="bedtime_intro">Lege fest, wann du schlafen gehst und aufstehst. Du wirst rechtzeitig erinnert, und der Wecker zum Aufwachen wird automatisch gestellt.</string>
<string name="get_started">Los geht\'s</string>
<string name="sleep_duration">%1$d Std. %2$d Min. Schlaf</string>
<string name="bedtime_schedule">Zeitplan aktiv</string>
<string name="bedtime_reminder">Erinnerung</string>
<string name="at_bedtime">Zur Schlafenszeit</string>
<string name="minutes_before">%1$d Min. vorher</string>
<string name="silence_after">Automatisch stumm nach</string>
<string name="snooze_length">Schlummerdauer</string>
<string name="alarm_volume">Lautstärke</string>
<string name="gradual_volume">Langsam lauter werden</string>
<string name="volume_buttons">Lautstärketasten beim Klingeln</string>
<string name="vb_volume">Lautstärke ändern</string>
<string name="vb_nothing">Nichts tun</string>
<string name="week_start">Woche beginnt am</string>
<string name="timer_vibrate">Timer vibriert</string>
<string name="style">Darstellung</string>
<string name="digital">Digital</string>
<string name="analog">Analog</string>
<string name="show_seconds">Sekunden anzeigen</string>
<string name="theme">Design</string>
<string name="theme_system">System</string>
<string name="theme_light">Hell</string>
<string name="theme_dark">Dunkel</string>
<string name="n_minutes">%1$d Minuten</string>
<string name="n_seconds">%1$d Sekunden</string>
<string name="ch_ringing">Klingeln</string>
<string name="ch_upcoming">Anstehende Wecker</string>
<string name="ch_missed">Verpasste Wecker</string>
<string name="ch_running">Laufende Timer und Stoppuhr</string>
<string name="ch_bedtime">Schlafenszeit</string>
<string name="notif_upcoming">Wecker um %1$s</string>
<string name="notif_snoozed">Schlummert bis %1$s</string>
<string name="notif_missed">Wecker verpasst</string>
<string name="notif_bedtime">Bald ist Schlafenszeit</string>
<string name="notif_bedtime_text">Leg das Handy weg und komm zur Ruhe.</string>
<string name="fsi_title">Wecker im Vollbild</string>
<string name="fsi_text">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.</string>
<string name="no_alarm_set">Kein Wecker gestellt</string>
<string name="widget_digital_name">Uhrzeit</string>
<string name="widget_digital_desc">Große Uhrzeit, Datum und nächster Wecker</string>
<string name="widget_analog_name">Analoge Uhr</string>
<string name="widget_analog_desc">Zifferblatt in deinen Farben</string>
<string name="widget_next_name">Nächster Wecker</string>
<string name="widget_next_desc">Wann der nächste Wecker klingelt</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_analog_desc"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="110dp" android:minHeight="110dp" android:targetCellWidth="2" android:targetCellHeight="2"
android:minResizeWidth="80dp" android:minResizeHeight="40dp"
android:resizeMode="horizontal|vertical" android:updatePeriodMillis="0"
android:widgetCategory="home_screen|keyguard" />
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_digital_desc"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="250dp" android:minHeight="110dp" android:targetCellWidth="4" android:targetCellHeight="2"
android:minResizeWidth="80dp" android:minResizeHeight="40dp"
android:resizeMode="horizontal|vertical" android:updatePeriodMillis="0"
android:widgetCategory="home_screen|keyguard" />
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_next_desc"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="180dp" android:minHeight="48dp" android:targetCellWidth="3" android:targetCellHeight="1"
android:minResizeWidth="80dp" android:minResizeHeight="40dp"
android:resizeMode="horizontal|vertical" android:updatePeriodMillis="0"
android:widgetCategory="home_screen|keyguard" />
@@ -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)
}
}
@@ -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")), {}, {}) }
}
+7
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
Binary file not shown.
+7
View File
@@ -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
Vendored Executable
+251
View File
@@ -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" "$@"
+94
View File
@@ -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
+16
View File
@@ -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")