Author SHA1 Message Date
MrBlakeandClaude Opus 5.5 b87683a0b0 docs: Uhr in README with screenshots and Obtainium link
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:17:23 +02:00
MrBlakeandClaude Opus 5.5 bc4cd8daf1 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]>
2026-10-17 15:16:50 +02:00
MrBlakeandClaude Opus 5.5 887299a3a7 replica(uhr): design tokens and component specs (39 pairs AA)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:59:51 +02:00
MrBlakeandClaude Opus 5.5 c163a6d578 replica(uhr): architecture and schema
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:58:56 +02:00
MrBlakeandClaude Opus 5.5 3c9535e1d5 replica(uhr): recon map, feature matrix, reference screenshots
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:58:04 +02:00
MrBlakeandClaude Opus 5.5 64d664fbe9 replica: one folder per app (replica/wetter)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:56:22 +02:00
126 changed files with 5171 additions and 12 deletions
+48 -9
View File
@@ -8,6 +8,7 @@
| App | Replaces | Status | Install | | App | Replaces | Status | Install |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| **Uhr** (`uhr/`) | Google Clock / Pixel Clock | alpha, in daily testing | [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.uhr%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Uhr%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.uhr%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EUhr%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Euhr-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D) · F-Droid: not yet |
| **Wetter** (`wetter/`) | Google Weather / Pixel Weather | alpha, in daily testing | [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.wetter%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Wetter%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.wetter%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EWetter%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Ewetter-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D) · F-Droid: not yet | | **Wetter** (`wetter/`) | Google Weather / Pixel Weather | alpha, in daily testing | [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.wetter%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Wetter%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.wetter%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EWetter%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Ewetter-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D) · F-Droid: not yet |
More apps will follow, one folder each. Each one goes through the same pipeline (see [How these apps are made](#how-these-apps-are-made)). More apps will follow, one folder each. Each one goes through the same pipeline (see [How these apps are made](#how-these-apps-are-made)).
@@ -42,6 +43,31 @@ A weather app with the look and feel of Pixel Weather: an animated sky that chan
Requires Android 10 (API 29) or newer. Requires Android 10 (API 29) or newer.
### Uhr
A clock and alarm app with the look and flow of the Pixel clock: alarms in expandable cards, world clock, several timers at once, stopwatch with laps, and a bedtime schedule.
- **Reliable alarms without Google.** Exact alarms via `AlarmManager.setAlarmClock` (exempt from Doze, shown in the status bar), rescheduled after reboot, time or time zone changes and app updates. Wall-clock based: 7:00 stays 7:00 across daylight saving and travel; the scheduling logic is unit-tested for DST gaps and zone changes.
- **Ringing done right:** a full-screen screen over the lock screen with slide to snooze or stop, alarm-stream audio that gets louder gradually, vibration, automatic silence after a set time, and notifications for upcoming, snoozed and missed alarms. Volume keys can snooze or stop.
- **No network permission at all.** World clock cities come from the system time zone database (names localised offline), sounds from the device, the app's own tone, or your own audio files.
- **Also:** days and labels per alarm, pause until a date, skip the next ring, timers with ring progress and +1:00 that keep running in the background with a live countdown notification, stopwatch with fastest/slowest laps, analog or digital clock, wallpaper colours (Material You), three home-screen widgets (live digital clock, analog clock, next alarm).
- **Coming next:** widget style options like Wetter, screensaver/night clock, sunrise alarm, sleep sounds, reordering world clocks.
<p align="center">
<img src="docs/screenshots/uhr/S01-alarms-dark.png" width="200" alt="Alarm list">
<img src="docs/screenshots/uhr/S02-expanded-dark.png" width="200" alt="Alarm expanded">
<img src="docs/screenshots/uhr/S11-ringing.png" width="200" alt="Ringing screen with slide to snooze or stop">
</p>
<p align="center">
<img src="docs/screenshots/uhr/S06-clock-dark.png" width="200" alt="World clock">
<img src="docs/screenshots/uhr/S08-timers-dark.png" width="200" alt="Timers">
<img src="docs/screenshots/uhr/S09-stopwatch-dark.png" width="200" alt="Stopwatch with laps">
</p>
<sub>Screenshots rendered by the app's screenshot tests with the fallback palette; on Android 12+ the app uses your wallpaper colours.</sub>
Requires Android 10 (API 29) or newer.
## Install with Obtainium ## Install with Obtainium
[Obtainium](https://github.com/ImranR98/Obtainium) installs and updates apps straight from this repository's releases, with no store in between. [Obtainium](https://github.com/ImranR98/Obtainium) installs and updates apps straight from this repository's releases, with no store in between.
@@ -65,6 +91,16 @@ If the button does not open Obtainium (some browsers block links to apps; the pa
obtainium://app/%7B%22id%22%3A%22app.wetter%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Wetter%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.wetter%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EWetter%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Ewetter-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D obtainium://app/%7B%22id%22%3A%22app.wetter%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Wetter%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.wetter%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EWetter%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Ewetter-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D
``` ```
### Uhr
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="54">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.uhr%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Uhr%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.uhr%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EUhr%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Euhr-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D)
Same setup as Wetter, with the filters `^Uhr ` (release titles) and `^uhr-.*\.apk$` (APKs). Raw link:
```
obtainium://app/%7B%22id%22%3A%22app.uhr%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Uhr%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.uhr%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EUhr%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Euhr-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D
```
**Releases.** Every app is published as a release titled `<App> <version>` (for example `Wetter 0.1.0`, tag `wetter-v0.1.0`) with one asset named `<app>-<version>.apk`. Obtainium shows *"no matching version"* until the first release of an app exists. **Releases.** Every app is published as a release titled `<App> <version>` (for example `Wetter 0.1.0`, tag `wetter-v0.1.0`) with one asset named `<app>-<version>.apk`. Obtainium shows *"no matching version"* until the first release of an app exists.
**F-Droid:** not available yet. The apps contain no proprietary libraries, so an F-Droid build is planned (either the main repository or a self-hosted F-Droid repo). **F-Droid:** not available yet. The apps contain no proprietary libraries, so an F-Droid build is planned (either the main repository or a self-hosted F-Droid repo).
@@ -73,20 +109,22 @@ obtainium://app/%7B%22id%22%3A%22app.wetter%22%2C%22url%22%3A%22https%3A%2F%2Fgi
### The Replica pipeline ### The Replica pipeline
Every app is rebuilt with **[Replica](https://github.com/Jakeschincariol/replica-skill)**, an open set of eleven Claude Code skills by Jake Schincariol for cloning an app's *functionality and UX patterns* clean-room style. Each skill writes files the next one reads, and everything is kept in [`replica/`](replica/): Every app is rebuilt with **[Replica](https://github.com/Jakeschincariol/replica-skill)**, an open set of eleven Claude Code skills by Jake Schincariol for cloning an app's *functionality and UX patterns* clean-room style. Each skill writes files the next one reads, and everything is kept in [`replica/<app>/`](replica/wetter/):
| Step | Skill | What it produced for Wetter | | Step | Skill | What it produced for Wetter |
| --- | --- | --- | | --- | --- | --- |
| 1 | `replica-recon` | [Recon map](replica/recon.md) built from public sources (help center, reviews, store listing): 15 screens, 9 user flows, components, inferred data model, [feature matrix](replica/features.csv) | | 1 | `replica-recon` | [Recon map](replica/wetter/recon.md) built from public sources (help center, reviews, store listing): 15 screens, 9 user flows, components, inferred data model, [feature matrix](replica/wetter/features.csv) |
| 2 | `replica-architect` | [Architecture](replica/architecture.md) and [schema](replica/schema.sql): native Kotlin, offline-first, no server, public APIs only | | 2 | `replica-architect` | [Architecture](replica/wetter/architecture.md) and [schema](replica/wetter/schema.sql): native Kotlin, offline-first, no server, public APIs only |
| 3 | `replica-design` | [Design tokens](replica/design/tokens.json) and [component specs](replica/design/components.md), WCAG AA contrast checked (70 colour pairs), generated into Compose code | | 3 | `replica-design` | [Design tokens](replica/wetter/design/tokens.json) and [component specs](replica/wetter/design/components.md), WCAG AA contrast checked (70 colour pairs), generated into Compose code |
| 4 | `replica-build` | The app itself, screen by screen, with a [build log](replica/build-log.md) and [screenshots](replica/clone-screens/) rendered from a real API response | | 4 | `replica-build` | The app itself, screen by screen, with a [build log](replica/wetter/build-log.md) and [screenshots](replica/wetter/clone-screens/) rendered from a real API response |
| next | `replica-test`, `replica-diff`, `replica-entrepreneur`, `replica-brand`, `replica-launch`, `replica-deploy`, `replica-backend` | QA, parity scoring against the original, reading real user reviews for improvements, naming and branding, store listing, release | | next | `replica-test`, `replica-diff`, `replica-entrepreneur`, `replica-brand`, `replica-launch`, `replica-deploy`, `replica-backend` | QA, parity scoring against the original, reading real user reviews for improvements, naming and branding, store listing, release |
**Uhr** went through the same steps from scratch: [recon](replica/uhr/recon.md) (17 screens, 9 flows), [architecture](replica/uhr/architecture.md), [design tokens](replica/uhr/design/tokens.json) (39 pairs AA), [build log](replica/uhr/build-log.md) and [screenshots](replica/uhr/clone-screens/).
The rules Replica enforces, and that this project follows: The rules Replica enforces, and that this project follows:
- **Public sources and our own devices only.** No decompiling, no private APIs, no reading the original's code or network traffic. - **Public sources and our own devices only.** No decompiling, no private APIs, no reading the original's code or network traffic.
- **Everything written fresh.** Code, icons, weather glyphs, the animated sky and every label are made from scratch. Reference screenshots of the originals live in `replica/screens/` for layout comparison only and never ship. - **Everything written fresh.** Code, icons, weather glyphs, the animated sky and every label are made from scratch. Reference screenshots of the originals live in `replica/<app>/screens/` for layout comparison only and never ship.
- **What cannot be cloned is left out on purpose:** Google's own forecast models, Gemini summaries, Calendar/Gmail-based insights and account sync. Open alternatives replace them where it makes sense. - **What cannot be cloned is left out on purpose:** Google's own forecast models, Gemini summaries, Calendar/Gmail-based insights and account sync. Open alternatives replace them where it makes sense.
### Built with AI, steered by a human ### Built with AI, steered by a human
@@ -104,10 +142,10 @@ Each commit says what changed and carries a `Co-Authored-By` line for the AI.
Requirements: JDK 17 or newer and the Android SDK (platform 36). Requirements: JDK 17 or newer and the Android SDK (platform 36).
```bash ```bash
cd wetter cd wetter # or: cd uhr
./gradlew :app:assembleRelease # APK in app/build/outputs/apk/release/ ./gradlew :app:assembleRelease # APK in app/build/outputs/apk/release/
./gradlew :app:testDebugUnitTest # unit + screenshot tests ./gradlew :app:testDebugUnitTest # unit + screenshot tests
./gradlew :app:recordRoborazziDebug # re-render replica/clone-screens/ ./gradlew :app:recordRoborazziDebug # re-render replica/wetter/clone-screens/
``` ```
Release builds are signed with the project key from `~/.android/nogoogleapps-release.properties` (kept outside the repository); without it they fall back to the debug key for local testing. `scripts/release.sh <app>` builds, signs and publishes a release in the format Obtainium expects. Release builds are signed with the project key from `~/.android/nogoogleapps-release.properties` (kept outside the repository); without it they fall back to the debug key for local testing. `scripts/release.sh <app>` builds, signs and publishes a release in the format Obtainium expects.
@@ -115,8 +153,9 @@ Release builds are signed with the project key from `~/.android/nogoogleapps-rel
## Repository layout ## Repository layout
``` ```
replica/ recon, architecture, design system, build log, screenshots replica/<app>/ recon, architecture, design system, build log, screenshots (one folder per app)
wetter/ the weather app (Kotlin, Jetpack Compose, Glance widgets, Room, WorkManager) wetter/ the weather app (Kotlin, Jetpack Compose, Glance widgets, Room, WorkManager)
uhr/ the clock and alarm app (Kotlin, Jetpack Compose, AlarmManager, Glance, Room)
``` ```
## Credits ## Credits
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+88
View File
@@ -0,0 +1,88 @@
# Architecture: Uhr (Android, ohne Google)
Basis: `recon.md`, `features.csv`. Same conventions as `wetter/`: one native app, no server, no accounts.
**No network permission at all.** World clock cities come from the system time zone database, sounds from the device and the app.
## Stack
| Schicht | Wahl | warum |
| --- | --- | --- |
| Sprache/UI | Kotlin + Jetpack Compose, Material 3 Expressive | same as Wetter, shared look and know-how |
| Persistenz | Room (alarms, instances, timers, cities) + DataStore (settings, stopwatch, bedtime) | relational where it matters, key-value for singletons |
| Alarme | `AlarmManager.setAlarmClock()` | exempt from Doze, shows the alarm icon in the status bar, the only API that is reliable for wake-up alarms |
| Klingeln | foreground service (type `systemExempted`/`mediaPlayback`) + full-screen-intent notification → ringing activity over the lock screen | the pattern Android expects for alarm clocks |
| Timer/Stoppuhr | state in DB/DataStore, `end_at` / `elapsedRealtime` based; foreground service with live notification only while running | survives process death; no ticking in the background |
| Widgets | Jetpack Glance + RemoteViews `TextClock`/`AnalogClock` | clocks tick without app updates |
| Bildschirmschoner | `DreamService` | system screensaver API |
| Ortsliste Weltuhr | `java.time.ZoneId` + localized names from ICU (`android.icu.util.TimeZone` display names, exemplar cities) | offline, no city API needed |
| Töne | `RingtoneManager` (system alarm sounds) + own files via `ACTION_OPEN_DOCUMENT` (persisted URI permission) + bundled CC0 sounds | no streaming services |
| Build/Verteilung | Gradle KTS, minSdk 29, targetSdk 36; release via `scripts/release.sh uhr` (Obtainium) | same pipeline as Wetter |
## Permissions
| permission | why |
| --- | --- |
| `USE_EXACT_ALARM` (API 33+), `SCHEDULE_EXACT_ALARM` (≤32) | alarm clock apps may use exact alarms without asking |
| `USE_FULL_SCREEN_INTENT` | ringing screen over the lock screen (Android 14+: check `canUseFullScreenIntent`, guide the user to the setting) |
| `POST_NOTIFICATIONS` | upcoming, snoozed, missed, timer, stopwatch |
| `FOREGROUND_SERVICE` (+ type) | ringing, running timers |
| `RECEIVE_BOOT_COMPLETED` | reschedule after reboot |
| `VIBRATE`, `WAKE_LOCK` | ringing |
No `INTERNET`, no location.
## Schema
See `schema.sql`: 4 tables (`alarms`, `alarm_instances`, `timers`, `cities`), singletons in DataStore. Access rules: single user, app-private storage, no RLS needed.
Hard constraints: one bedtime wake alarm (partial unique index); a running timer always has `end_at` (check constraint); one instance row per alarm (primary key).
## Interne API (Repositories / Use Cases)
| Aufruf | macht | Flow |
| --- | --- | --- |
| `AlarmRepo.observe()` | alarms + next instance, sorted by time | F01 |
| `AlarmRepo.upsert(alarm)` | save, then `Scheduler.reschedule(alarm)` | F01, F02 |
| `AlarmRepo.delete(id)` / `undo` | delete, cancel PendingIntent | F02 |
| `Scheduler.reschedule(alarm)` | compute next fire (days, skip_until, DST-safe via `ZonedDateTime`), write instance, `setAlarmClock` | all |
| `Scheduler.rescheduleAll()` | on boot, `TIME_SET`, `TIMEZONE_CHANGED`, app update | — |
| `Ringing.start(alarmId)` | foreground service: sound (gradual volume), vibration, full-screen activity, silence-after timeout | F03 |
| `Ringing.snooze()` / `dismiss()` | instance → snoozed (fire_at + snooze) / next occurrence | F03 |
| `AlarmRepo.dismissNext(id)` | skip the upcoming ring only | F04 |
| `TimerRepo.add/pause/resume/addMinute/reset/delete` | state transitions with `end_at`, schedule `setExactAndAllowWhileIdle` for the end | F05 |
| `Stopwatch.start/stop/lap/reset` | DataStore singleton | F06 |
| `CityRepo.search(q)` / `add` / `reorder` / `delete` | offline zone search | F07 |
| `Bedtime.save(schedule)` | writes bedtime + the wake alarm (`is_bedtime_wake`) + reminder alarm | F08 |
| `Sounds.list()` / `preview(uri)` | ringtones + bundled + picked files | F09 |
### Background
| Auslöser | macht |
| --- | --- |
| `setAlarmClock` fire → `AlarmReceiver` | start ringing service |
| 2 h before an alarm (inexact alarm) | "upcoming alarm" notification with "Dismiss now" |
| silence-after timeout | stop ringing, instance → missed, missed notification |
| `BOOT_COMPLETED`, `TIME_SET`, `TIMEZONE_CHANGED`, `MY_PACKAGE_REPLACED` | `rescheduleAll()` |
| timer end (exact alarm) | timer ringing |
| bedtime reminder (exact) | reminder notification |
## Was beißt
- **Time zones and DST:** alarms are wall-clock; the next fire is computed with `ZonedDateTime` in the current zone, so 7:00 stays 7:00 after a zone change; a time skipped by DST rings at the first valid minute after.
- **Doze / OEM battery killers:** only `setAlarmClock` is reliable; show a one-time hint for battery optimization on aggressive OEMs.
- **Android 14+ full-screen intent:** may be off for sideloaded apps → detect and explain, fall back to a heads-up notification that still rings.
- **Race conditions:** snooze and dismiss from the notification and the activity at the same time → all transitions go through one `Mutex` in the ringing service.
- **Process death:** timers and stopwatch keep only timestamps, never counters.
- **Two alarms at the same time:** ring both labels in one screen; the second does not cancel the first.
- **Sounds:** a picked file can disappear → fall back to the default sound, never ring silently by accident.
- **No network:** nothing to rate-limit, nothing to leak; app is F-Droid friendly.
## Build-Reihenfolge
1. **Vertical slice** (S01, S03, S11): add an alarm, it rings at the time with sound over the lock screen, stop. Tables `alarms`, `alarm_instances`. Proves AlarmManager + full-screen intent.
2. **Must alarms** (S02, S04, S15): expanded card, days, label, vibrate, sound picker, snooze, delete/undo, snoozed notification, settings for snooze/silence-after, reboot/time-change handling.
3. **Timer + stopwatch** (S08, S09, S12): keypad, multiple timers, ring progress, background notifications, laps. Table `timers`.
4. **World clock** (S06, S07): local time, cities offline, reorder/delete, analog/digital. Table `cities`.
5. **Design pass**: expressive cards, bottom bar, big time typography, slide-to-act.
6. **Widgets** (S16): digital, analog, next alarm, style options like Wetter.
7. **Should**: pause alarm, dismiss early, upcoming/missed notifications, gradual volume, volume buttons, bedtime (S10), own sound files, settings (S13).
8. **Could**: sunrise alarm, sleep sounds, screensaver (S14), world clock widget, automatic home clock.
+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

+106
View File
@@ -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
}
+79
View File
@@ -0,0 +1,79 @@
# Komponenten: Uhr (Compose, Material 3 Expressive)
Tokens: `tokens.json` → `Tokens.kt` (`gen_theme.py`). On Android 12+ the accent roles come from dynamic colour (wallpaper), the rest from tokens.
Icons: Material Symbols Rounded (Apache 2.0). Clock faces, launcher icon and bundled sounds are our own. All labels written fresh, in German.
```
BottomNav
items Wecker, Uhr, Timer, Stoppuhr, Schlafenszeit (icon + label)
states selected: pill indicator 64×32 (nav-indicator) behind the icon, label bold
a11y NavigationBar items with role tab, selected state announced
used on S01, S06, S08, S09, S10
TopBar
content screen title (title 22sp) left, overflow menu (Einstellungen, Bildschirmschoner) right
used on all tabs
AlarmCard
collapsed label (label 14sp, muted) + chevron in a small tonal circle; big time "6:00" (alarm-time 56sp/600) + "AM" (alarm-ampm) when 12h;
bottom row: next ring text ("Morgen", "Heute", "Mo, Di, Mi") + Switch
expanded label field with tag icon, time, day text + switch, day chips row, rows: Pausieren (+), Ton (name), Vibration (check), Löschen
states on, off (time text-off), snoozed ("Schlummert bis 7:10" chip), skipping ("Übersprungen bis …"), expanding (spring)
tokens card / dark-card, radius card 28, padding 20, gap 8
a11y card is one button "Wecker 6 Uhr, Morgen, an"; switch separate; expand state announced
used on S01, S02
DayChips
7 circles 36dp, letters by locale and week start; selected chip-on, unselected outline
a11y toggleable, full weekday name
used on S02, S10
Fab
96dp circle, fab colour, "+" icon; centred above the nav bar
used on S01, S06, S08
TimePicker
M3 TimePicker dial with toggle to TimeInput; 24h by system setting
used on S03, S10
SoundPicker (S04)
sections Eigene Töne (picked files, + "Datei hinzufügen"), Wecktöne (system), Mitgeliefert (bundled); radio list, tap previews, playing shows equalizer icon
states playing, selected, missing file (fallback note)
RingingScreen (S11)
bg ring-bg (or dynamic tertiary container), full screen over lock screen
content time (ringing 104sp, light), label; slide control at the bottom
SlideToAct track pill (ring-track) with "Schlummern" left, "Stopp" right, thumb circle with alarm icon in the middle;
drag past 40 % to act, spring back otherwise; tap the side labels also works (accessibility)
a11y custom actions Schlummern / Stopp; volume keys per settings
TimerCard
content label, ring (timer-track / timer-progress, 6dp) with remaining time inside (timer-time) and reset icon, +1:00 secondary pill, play/pause primary pill, close ×
states running (ring shrinks), paused (time blinks), ringing (pulsing, time counts up negative), finished
used on S08
Keypad
display "00h 00m 00s", typed digits fill from the right; 3×4 keys circle 72dp, 00 and backspace; start FAB appears when not empty
used on S08
Stopwatch (S09)
time mm:ss.cc (stopwatch 72sp, tabular), hours appear when needed
buttons full-width pills, primary (stop/start: stop colour), secondaries (Zurücksetzen, Runde: secondary-pill)
laps list "Runde 3 00:12.34 01:02.56", fastest/slowest marked
states idle (Start only), running (Stopp, Runde), stopped (Start, Zurücksetzen)
ClockHeader (S06)
big time clock-big with optional seconds, date, next alarm line; analog face variant (own design: round face, accent hour hand, light minute hand, dot)
CityCard
name, "Morgen · +7 Std.", time; swipe to delete, long press to reorder
used on S06
SettingsKit
same grouped tiles as Wetter (SettingsGroup / Item / Switch / Choice)
Notifications
upcoming (Jetzt beenden), snoozed (Beenden), missed, timer running (chronometer, Pause, +1:00), stopwatch running (chronometer, Runde, Pause)
Widgets
Digital (TextClock big + date + next alarm), Analog (own face as AnalogClock drawables), Next alarm pill; style options like Wetter (palette, theme, opacity)
```
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""tokens.json -> Tokens.kt (Compose). Re-run after every token change."""
import json, re, sys, pathlib
src = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "tokens.json")
out = pathlib.Path(sys.argv[2] if len(sys.argv) > 2 else src.with_name("Tokens.kt"))
t = json.loads(src.read_text())
camel = lambda s: re.sub(r"-(\w)", lambda m: m.group(1).upper(), s)
L = ["// 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 {"]
L += [f" val {camel(k)} = Color(0xFF{v.lstrip('#').upper()})" for k, v in t["color"].items()]
L += ["}", "", "object WType {"]
for k, v in t["type"].items():
L.append(f" val {camel(k)} = TextStyle(fontSize = {v['size']}.sp, lineHeight = {v['line']}.sp, fontWeight = FontWeight.W{v['weight']})")
L += ["}", "", "object WSpace {"]
L += [f" val s{v} = {v}.dp" for v in t["space"]]
L += ["}", "", "object WRadius {"]
L += [f" val {camel(k)} = {v}.dp" for k, v in t["radius"].items()]
L += ["}", ""]
out.write_text("\n".join(L))
print("wrote", out)
+78
View File
@@ -0,0 +1,78 @@
{
"_about": "Uhr tokens. Roles read by eye from replica/uhr/screens/ref-01..07 (Pixel Clock, dark first). Own values, no brand colour copied; on Android 12+ dynamic colour (wallpaper) replaces the accent roles. Check: python3 ~/.claude/skills/replica-design/contrast.py replica/uhr/design/tokens.json",
"color": {
"bg": "#f8f9ff", "surface": "#ffffff", "card": "#eef0f6", "card-high": "#e2e5ec",
"border": "#c3c6cf", "border-input": "#73777f",
"text": "#191c20", "text-muted": "#43474e", "text-off": "#5c6068",
"accent": "#2f5ea8", "on-accent": "#ffffff",
"accent-container": "#d6e3ff", "on-accent-container": "#001b3e",
"fab": "#a8c7fa", "on-fab": "#0b2a55",
"chip-on": "#2f5ea8", "on-chip-on": "#ffffff",
"secondary-pill": "#dfe2eb", "on-secondary-pill": "#191c20",
"stop": "#ffd8e4", "on-stop": "#4a1426",
"danger": "#ba1a1a",
"nav": "#eceef4", "nav-indicator": "#d6e3ff",
"dark-bg": "#111318", "dark-surface": "#111318", "dark-card": "#1d2024", "dark-card-high": "#272a2f",
"dark-border": "#43474e", "dark-border-input": "#8d9199",
"dark-text": "#e2e2e9", "dark-text-muted": "#c3c6cf", "dark-text-off": "#9a9ea6",
"dark-accent": "#a8c7fa", "dark-on-accent": "#062e6f",
"dark-accent-container": "#244a80", "dark-on-accent-container": "#d6e3ff",
"dark-fab": "#a8c7fa", "dark-on-fab": "#062e6f",
"dark-chip-on": "#a8c7fa", "dark-on-chip-on": "#062e6f",
"dark-secondary-pill": "#3a3d43", "dark-on-secondary-pill": "#e2e2e9",
"dark-stop": "#f2b8c9", "dark-on-stop": "#4a1426",
"dark-danger": "#ffb4ab",
"dark-nav": "#1d2024", "dark-nav-indicator": "#3a3f47",
"ring-bg": "#1e5a52", "ring-on": "#b4f1e6", "ring-track": "#2b6e65", "ring-thumb": "#3f9a7f", "on-ring-thumb": "#ffffff",
"timer-track": "#3a3d43", "timer-progress": "#7ec4a8",
"night-text": "#c4433a", "night-bg": "#000000"
},
"pairs": [
["text","bg"],["text","card"],["text","card-high"],["text-muted","bg"],["text-muted","card"],["text-off","card"],
["on-accent","accent"],["on-accent-container","accent-container"],["on-fab","fab"],["on-chip-on","chip-on"],
["on-secondary-pill","secondary-pill"],["on-stop","stop"],["accent","card"],["danger","card"],["text","nav"],["text","nav-indicator"],
["border-input","surface","ui"],
["dark-text","dark-bg"],["dark-text","dark-card"],["dark-text","dark-card-high"],["dark-text-muted","dark-bg"],["dark-text-muted","dark-card"],["dark-text-off","dark-card"],
["dark-on-accent","dark-accent"],["dark-on-accent-container","dark-accent-container"],["dark-on-fab","dark-fab"],["dark-on-chip-on","dark-chip-on"],
["dark-on-secondary-pill","dark-secondary-pill"],["dark-on-stop","dark-stop"],["dark-accent","dark-card"],["dark-danger","dark-card"],
["dark-text","dark-nav"],["dark-text","dark-nav-indicator"],["dark-border-input","dark-card","ui"],
["ring-on","ring-bg"],["ring-on","ring-track"],["on-ring-thumb","ring-thumb","large"],
["timer-progress","dark-card","ui"],["night-text","night-bg","large"]
],
"font": {
"sans": "Roboto Flex (OFL), system fallback",
"display": "Roboto Flex, weight 500-600, tabular numbers, for alarm times",
"clock": "Roboto Flex, weight 300-400 for big clocks and the ringing screen",
"mono": "tabular figures (fontFeatureSettings tnum) instead of a mono font for the stopwatch"
},
"type": {
"label-sm": {"size": 12, "line": 16, "weight": 500},
"label": {"size": 14, "line": 20, "weight": 500},
"body-sm": {"size": 14, "line": 20, "weight": 400},
"body": {"size": 16, "line": 24, "weight": 400},
"title": {"size": 22, "line": 28, "weight": 400},
"headline": {"size": 28, "line": 36, "weight": 400},
"alarm-time": {"size": 56, "line": 60, "weight": 600},
"alarm-ampm": {"size": 20, "line": 24, "weight": 600},
"timer-time": {"size": 44, "line": 48, "weight": 400},
"clock-big": {"size": 88, "line": 92, "weight": 400},
"stopwatch": {"size": 72, "line": 76, "weight": 500},
"ringing": {"size": 104, "line": 108, "weight": 400}
},
"space": [0, 2, 4, 8, 12, 16, 20, 24, 32, 48],
"radius": {"xs": 8, "sm": 12, "md": 16, "lg": 24, "card": 28, "pill": 999, "nav-indicator": 16},
"shape": {
"fab": "circle 96dp (large, centred above the nav bar)",
"stopwatch-primary": "pill 72dp tall, full width",
"timer-ring": "circle stroke 6dp",
"day-chip": "circle 36dp"
},
"motion": {
"card-expand": "spring dampingRatio 0.85 stiffness 380",
"slide-act": "spring back dampingRatio 0.7",
"fast": "120ms", "base": "250ms"
},
"layout": {"gutter": 16, "card-gap": 8, "nav-height": 80, "fab-bottom": 16, "card-padding": 20}
}
+57
View File
@@ -0,0 +1,57 @@
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 yes
3 Add alarm with time picker (dial and keyboard) alarm must yes yes
4 Expandable alarm card (edit in place) alarm must yes yes
5 Repeat days chips alarm must yes yes
6 Alarm label alarm must yes yes
7 Delete alarm with undo alarm must yes yes
8 Vibrate toggle alarm must yes yes
9 Alarm sound picker with preview (system ringtones) alarm must yes yes
10 Own audio file as alarm sound alarm should yes partial picker works; file loss falls back to default; not device-tested
11 Pause alarm until date alarm should yes yes
12 Skip next occurrence (dismiss early) alarm should yes yes from notification
13 Exact scheduling that survives reboot time change and Doze alarm must yes yes AlarmManager.setAlarmClock
14 Full-screen ringing screen over lock screen ringing must yes yes
15 Slide control snooze/stop ringing must yes yes
16 Snooze length setting settings must yes yes
17 Silence after setting settings must yes yes
18 Gradually increase volume settings should yes yes
19 Alarm volume setting settings should yes yes
20 Volume buttons behaviour (snooze/stop/volume/none) settings should yes yes
21 Start week on settings could yes yes
22 Upcoming alarm notification with dismiss now notifications should yes yes
23 Missed alarm notification notifications should yes yes
24 Snoozed notification with dismiss notifications must yes yes
25 World clock: local time and date clock must yes yes
26 World clock: add city via search clock must yes yes
27 World clock: reorder and swipe to delete clock should yes partial swipe to delete only
28 Analog or digital clock style clock should yes yes
29 Show seconds clock could yes yes
30 Automatic home clock when travelling clock could yes no
31 Timer keypad h m s timer must yes yes
32 Run several timers at once timer must yes yes
33 Timer ring progress +1:00 pause reset delete timer must yes yes
34 Timer labels timer could yes partial stored and shown; no UI to edit yet
35 Timer runs in background with notification timer must yes yes
36 Timer ringing screen timer must yes yes
37 Timer sound and vibrate settings settings should yes partial vibrate setting only; sound uses default
38 Stopwatch start stop reset stopwatch must yes yes
39 Stopwatch laps stopwatch must yes yes
40 Stopwatch running notification stopwatch should yes yes
41 Bedtime schedule with wake-up alarm bedtime should yes yes
42 Bedtime reminder notification bedtime should yes 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 yes
47 Analog clock widget widgets should yes yes
48 World clock widget widgets could yes no
49 Next alarm widget widgets should yes 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 yes
52 Expressive design (bottom bar pill cards shapes) display must yes 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
+141
View File
@@ -0,0 +1,141 @@
# Recon map: Google Clock / Pixel Clock (Android)
Scope: the whole app: alarms, world clock, timer, stopwatch, bedtime, alarm ringing screen, settings, screensaver, home-screen widgets
For: personal use, a clock and alarm app without Google services (project NoGooleApps)
Date: 2026-10-17
## Sources
| # | source | URL | notes |
| --- | --- | --- | --- |
| 1 | help center | https://support.google.com/clock/?hl=en | full settings list (alarms, time and date, display), world clock, timer, widget |
| 2 | help center | https://support.google.com/clock/answer/9887159?hl=en | bedtime schedule, reminder, bedtime mode, sunrise alarm, sleep sounds |
| 3 | review | https://www.slashgear.com/1540180/android-clock-app-features-you-dont-know | sounds, pause alarm, volume buttons, snooze range, alarm duration, screensaver |
| 4 | app store listing | https://play.google.com/store/apps/details?id=com.google.android.deskclock | pitch only, no screenshots saved |
| 5 | screenshots | replica/uhr/screens/ref-01..07.png | supplied by user: alarm list (dark), world clock + settings, timer/alarm expanded/alarm sound, missed-alarm notification, ringing screen, launcher icon, stopwatch. Reference only, never shipped |
## Core loop
Set an alarm in a few taps and be woken reliably at that time, every time, even with battery saver and Do Not Disturb.
## Screens
| ID | screen | route / how to reach | purpose | key components | states seen |
| --- | --- | --- | --- | --- | --- |
| S01 | Alarm list | tab "Alarm" (default) | all alarms, on/off | top bar + overflow, alarm cards (label, big time + AM/PM, next-ring text, switch, expand chevron), FAB "+" | empty, filled, alarm off (dimmed time), section headers ("Wake up" = bedtime alarm, "Other") (ref-01, ref-03) |
| S02 | Alarm expanded | tap a card | edit one alarm in place | label field, time, day chips S M T W T F S, switch, rows: pause alarm, sound, vibrate, delete | expanded (ref-03) |
| S03 | Time picker | tap time / FAB | pick hour and minute | M3 clock dial / keyboard input toggle | dial, input |
| S04 | Alarm sound | S02 → sound row | choose ringtone | tabs (sounds; streaming partners in original), list with play preview, "add new" (own file) | playing, selected (ref-03) |
| S05 | Pause alarm | S02 → pause row | skip until a date | date picker | |
| S06 | Clock (world clock) | tab "Clock" | local time + cities | big time with seconds option, date, next alarm, weather line, city cards (name, day offset, time) , FAB add | one city, many (ref-02) |
| S07 | Add city | S06 FAB | search a city | search field, result list | empty, results |
| S08 | Timer | tab "Timer" | set and run timers | keypad (h m s) + start; running cards: label, ring progress, remaining, +1:00, pause/play, reset, delete; FAB add | idle keypad, running, paused, ringing, several timers (ref-03) |
| S09 | Stopwatch | tab "Stopwatch" | measure time, laps | big 00:00.00, buttons start/stop, reset, lap (large pill buttons), lap list | idle, running, stopped, laps (ref-07) |
| S10 | Bedtime | tab "Bedtime" | sleep schedule | first-run "get started", bedtime + wake-up times, days, sleep duration, reminder, sunrise alarm, sleep sounds | empty, set |
| S11 | Alarm ringing | full screen when an alarm fires | snooze or stop | big time, label, swipe/slide control "Snooze ← ⏰ → Stop", optional weather after stop | ringing, snoozed (ref-05) |
| S12 | Timer ringing | full screen / notification | stop or +1:00 | | |
| S13 | Settings | overflow → Settings | alarm, time, display settings | rows (silence after, snooze length, alarm volume, gradually increase volume, volume buttons, start week on, home time zone, automatic home clock, style analog/digital, seconds, night mode, timer sound/vibrate) | (ref-02) |
| S14 | Screensaver / bedside | overflow → Screensaver; system dream | dim clock on dark screen | analog/digital clock, night mode (red, dim) | day, night |
| S15 | Notifications | system | upcoming alarm (dismiss early), snoozed, missed alarm, running timer/stopwatch | notification actions | missed (ref-04) |
| S16 | Widgets | launcher | time on home screen | digital, analog, world clock list, next alarm | sizes |
| S17 | Lock-screen / quick settings tile | system | next alarm, start timer | | guess |
## Flows
```
F01 Set a new alarm
S01 -> FAB -> S03 pick time -> OK -> S01 (new card, "in 7 h 12 min" toast)
happy path clicks: 3 (FAB, dial hour+minute, OK)
edge: same time exists, exact-alarm permission revoked, notifications off, battery saver
F02 Make an alarm repeat on weekdays and name it
S01 -> tap card -> S02 -> tap 5 day chips -> label -> collapse
happy path clicks: 8
F03 Wake up
S11 rings -> snooze (slide left) -> rings again after 10 min -> stop (slide right)
edge: volume buttons set to snooze/stop, phone locked, DND, silent mode, reboot before alarm
F04 Skip tomorrow's alarm
S15 "upcoming alarm" notification -> "Dismiss now" or S02 -> pause until date (S05)
F05 Run a timer
S08 keypad 5 0 0 -> start -> running card -> +1:00 -> rings (S12) -> stop
happy path clicks: 4
edge: app closed, several timers at once, timer ends while screen off
F06 Measure laps
S09 start -> lap ×n -> stop -> reset
F07 Add a world clock
S06 -> FAB -> S07 search -> tap city -> S06
edge: reorder by long press, delete by swipe
F08 Set a bedtime schedule
S10 get started -> wake-up time + days -> bedtime -> reminder -> done (creates the "Wake up" alarm in S01)
F09 Change alarm sound
S02 -> sound -> S04 -> preview -> select / add own file
```
## Components
| component | variants | states | used on |
| --- | --- | --- | --- |
| Bottom navigation bar | 5 destinations with pill indicator | selected, unselected | all tabs |
| Top app bar | title + overflow menu | | all tabs |
| Alarm card | collapsed, expanded | on, off (dim), ringing soon, snoozed | S01, S02 |
| Big time text | alarm (56sp bold + small AM/PM), clock (very large), stopwatch (mono digits), ringing (huge) | | S01, S06, S09, S11 |
| Switch | M3 with thumb | on, off | S01, S02, S13 |
| Day chips | 7 round chips | selected (filled), unselected (outline) | S02, S10 |
| Settings row | icon + text + trailing (plus, check, chevron) | | S02 |
| FAB | large round "+" | | S01, S06, S08 |
| Timer card | ring progress, time, +1:00 pill, play/pause big pill, close | running, paused, ringing | S08 |
| Keypad | 0-9, 00, backspace, display h m s | empty, typing | S08 |
| Big pill buttons | primary (start/stop), secondary (reset, lap) | | S09, S08 |
| City card | name, offset text, time | dragging, swiping | S06 |
| Slide-to-act control | snooze ← → stop | idle, dragging | S11 |
| Time picker | dial, input | | S03, S10 |
| Notification | upcoming, snoozed, missed, timer running, stopwatch running | | S15 |
| Analog clock face | own design | | S06, S14, S16 |
## Inferred data model
```
Alarm id, hour, minute, enabled, days (bitmask Mon..Sun), label, ringtone_uri, vibrate,
snooze_until (nullable), skip_until_date (pause), is_bedtime_wake, created_at
evidence: S01, S02, help "Set, cancel, or snooze alarms" confidence: high
AlarmInstance alarm_id, fire_at (UTC instant), state (scheduled | snoozed | ringing | missed | dismissed)
evidence: S11, S15 notifications confidence: medium (needed for snooze/missed)
Timer id, label, duration_ms, remaining_ms, state (idle | running | paused | ringing), end_at, created_at
evidence: S08 cards confidence: high
Stopwatch state, started_at, accumulated_ms, laps[] (lap_ms, total_ms)
evidence: S09 confidence: high (single instance)
City id, name, country, timezone, sort_order
evidence: S06, S07 confidence: high
Bedtime enabled, bed_hour, bed_minute, wake_alarm_id, days, reminder_minutes, sunrise (bool), sleep_sound
evidence: S10, help "Set a bedtime schedule" confidence: high
Settings silence_after_min, snooze_min, alarm_volume, gradual_volume_s, volume_buttons (snooze|stop|volume|none),
week_start, home_timezone, auto_home_clock, clock_style (analog|digital), show_seconds,
timer_sound, timer_vibrate, night_mode, theme
evidence: S13 confidence: high
```
Relationships: Alarm 1-n AlarmInstance, Bedtime 1-1 Alarm (wake-up). No accounts.
## Feature matrix
See `features.csv`. Must: 26, should: 17, could: 8, skip: 5.
## Out of scope (cannot or should not be cloned)
- Streaming alarm sounds (YouTube Music, Spotify, Pandora, Calm): partner deals and accounts. Own audio files from the device replace them.
- Google Assistant Routines: Google service.
- Bedtime mode (silence + greyscale), sleep tracking, cough/snore detection, recent bedtime activity: Digital Wellbeing and system privileges, not available to normal apps.
- Calendar "upcoming events" card: needs Google Calendar; could later read the local calendar provider.
- Weather after the alarm: possible later via the Wetter app's data, not part of the first build.
- Google's clock face, ringtones and icons: own designs and own/CC0 sounds.
## Size
Screens 17, flows 9, entities 7. Hard parts: (1) reliable alarms (exact alarms, full-screen intent, Doze, reboot, time zone and DST changes), (2) timer and stopwatch running correctly in the background with live notifications, (3) the expressive UI (expanding cards, ring progress, slide-to-act) and widgets. Size: M.
+57
View File
@@ -0,0 +1,57 @@
-- Local SQLite schema (Room). No server, no accounts, no network: all data stays on the device.
-- Instants are INTEGER epoch milliseconds UTC. Alarm times are wall-clock (hour, minute) in the device zone.
CREATE TABLE alarms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hour INTEGER NOT NULL CHECK (hour BETWEEN 0 AND 23),
minute INTEGER NOT NULL CHECK (minute BETWEEN 0 AND 59),
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
days INTEGER NOT NULL DEFAULT 0 CHECK (days BETWEEN 0 AND 127), -- bit 0 = Monday … bit 6 = Sunday; 0 = once
label TEXT NOT NULL DEFAULT '',
ringtone_uri TEXT, -- NULL = default alarm sound, 'silent' = none
vibrate INTEGER NOT NULL DEFAULT 1 CHECK (vibrate IN (0,1)),
skip_until TEXT, -- ISO date: paused until (inclusive)
is_bedtime_wake INTEGER NOT NULL DEFAULT 0 CHECK (is_bedtime_wake IN (0,1)),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX alarms_time ON alarms(hour, minute);
CREATE UNIQUE INDEX alarms_one_bedtime ON alarms(is_bedtime_wake) WHERE is_bedtime_wake = 1;
-- The next concrete ring of an alarm. Exactly one row per enabled alarm; rewritten after every change,
-- reboot, time or time zone change. Snooze and "dismiss early" live here, not on the alarm.
CREATE TABLE alarm_instances (
alarm_id INTEGER PRIMARY KEY REFERENCES alarms(id) ON DELETE CASCADE,
fire_at INTEGER NOT NULL,
state TEXT NOT NULL DEFAULT 'scheduled'
CHECK (state IN ('scheduled','snoozed','ringing','missed','dismissed')),
updated_at INTEGER NOT NULL
);
CREATE INDEX alarm_instances_fire ON alarm_instances(state, fire_at);
CREATE TABLE timers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL CHECK (duration_ms > 0),
remaining_ms INTEGER NOT NULL CHECK (remaining_ms >= 0), -- valid while paused/idle
end_at INTEGER, -- set while running
state TEXT NOT NULL DEFAULT 'running' CHECK (state IN ('running','paused','ringing','idle')),
created_at INTEGER NOT NULL,
CHECK ((state = 'running') = (end_at IS NOT NULL))
);
CREATE INDEX timers_state ON timers(state, end_at);
CREATE TABLE cities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
zone_id TEXT NOT NULL UNIQUE, -- IANA, e.g. Europe/Paris
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX cities_sort ON cities(sort_order);
-- Single stopwatch and bedtime schedule live in DataStore (one instance each, no relations):
-- stopwatch: state (idle|running|stopped), started_at_elapsed, accumulated_ms, laps (json array of ms)
-- bedtime: enabled, bed_hour, bed_minute, days, reminder_min, sunrise, sleep_sound
-- settings: silence_after_min, snooze_min, alarm_volume, gradual_volume_s, volume_buttons,
-- week_start, home_zone, auto_home_clock, clock_style, show_seconds,
-- timer_sound, timer_vibrate, night_mode, theme
Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

Before

Width:  |  Height:  |  Size: 906 KiB

After

Width:  |  Height:  |  Size: 906 KiB

Before

Width:  |  Height:  |  Size: 896 KiB

After

Width:  |  Height:  |  Size: 896 KiB

Before

Width:  |  Height:  |  Size: 800 KiB

After

Width:  |  Height:  |  Size: 800 KiB

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Before

Width:  |  Height:  |  Size: 846 KiB

After

Width:  |  Height:  |  Size: 846 KiB

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Before

Width:  |  Height:  |  Size: 978 KiB

After

Width:  |  Height:  |  Size: 978 KiB

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 256 KiB

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Before

Width:  |  Height:  |  Size: 890 KiB

After

Width:  |  Height:  |  Size: 890 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -1,5 +1,5 @@
{ {
"_about": "Wetter-Klon tokens. Roles measured by eye from replica/screens/ref-01..04 (Pixel Weather, 2024-25). Values are own approximations, no brand colour copied. Names stay, values change in /replica-brand. Check: python3 ~/.claude/skills/replica-design/contrast.py replica/design/tokens.json", "_about": "Wetter-Klon tokens. Roles measured by eye from replica/screens/ref-01..04 (Pixel Weather, 2024-25). Values are own approximations, no brand colour copied. Names stay, values change in /replica-brand. Check: python3 ~/.claude/skills/replica-design/contrast.py replica/wetter/design/tokens.json",
"color": { "color": {
"bg": "#f8f9ff", "bg": "#f8f9ff",
"surface": "#ffffff", "surface": "#ffffff",

Before

Width:  |  Height:  |  Size: 499 KiB

After

Width:  |  Height:  |  Size: 499 KiB

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Before

Width:  |  Height:  |  Size: 330 KiB

After

Width:  |  Height:  |  Size: 330 KiB

Before

Width:  |  Height:  |  Size: 866 KiB

After

Width:  |  Height:  |  Size: 866 KiB

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

Some files were not shown because too many files have changed in this diff Show More