Author SHA1 Message Date
MrBlakeandClaude Opus 5.5 7812f62c0f uhr: sleep sounds, sunrise alarm, reorder world clock, rename timers; 0.2.0
- own generated sleep sounds (rain, waves, brown noise) with sleep timer and fade-out
- sunrise: 15 min before the bedtime wake alarm, a full-screen screen brightens from ember to daylight
- world clock: long-press drag to reorder, with haptic ticks; order persists
- timers: tap the title to rename

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:49:09 +02:00
MrBlakeandClaude Opus 5.5 08633f918c uhr: smooth card expand, audible sound demos, adaptive charging note; 0.1.2
- one spring for expand, content fade and neighbour placement; fixed-height header with
  cross-faded label; rotating chevron (release build: 0.5 % janky frames while toggling)
- sound preview on the alarm stream (audible in silent/DND), 8 s demo, playing indicator
- settings explain that alarms are published via setAlarmClock, which system adaptive
  charging uses; apps cannot control charging themselves

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:28:36 +02:00
MrBlakeandClaude Opus 5.5 62867fcd07 fix(uhr): time picker digits clipped, dial haptics; 0.1.1
- the theme mapped displayLarge to the 88sp clock style, which the M3 time picker uses for its
  hour/minute fields; displayLarge/headline are back to M3 defaults
- tick on every value the dial passes, firmer click when hours settle and on OK

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:22:32 +02:00
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
MrBlakeandClaude Opus 5.5 4b5c9bdf85 widgets: reload data on update, clock widget, picker previews; README screenshots; 0.2.0
- Glance update() only recomposed the running session, so saved styles (opacity, colours)
  and new forecasts never reached placed widgets; a state version now triggers a reload
- new minimal clock widget (live TextClock, date, current weather), transparent by default
- static preview images for the widget picker, generated previews on Android 15+
- debug builds install next to the release app (app.wetter.debug)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:51:16 +02:00
MrBlakeandClaude Opus 5.5 d1d8d01dd0 build: release script for Obtainium-compatible Gitea releases
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:41:38 +02:00
158 changed files with 6125 additions and 34 deletions
+68 -11
View File
@@ -8,6 +8,7 @@
| 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 |
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)).
@@ -20,9 +21,52 @@ A weather app with the look and feel of Pixel Weather: an animated sky that chan
- **No account, no tracking, no analytics.** Everything stays on the device. Coordinates are rounded to two decimals (about 1 km) before they reach the weather service.
- **Open data sources only:** [Open-Meteo](https://open-meteo.com) for forecasts, 15-minute precipitation, air quality, pollen and place search (CC BY 4.0), and [Nominatim / OpenStreetMap](https://nominatim.org) for naming your current location.
- **Features today:** current location and saved cities (swipe between them), search with a full preview before saving, precipitation-in-the-next-hours card, a rule-based daily summary, day detail view, units and theme settings, light and dark mode.
- **Five home-screen widgets:** a weather card that grows from 3×2 to 4×5 (hourly panel, then a daily list), a pill, a tilted pill, a one-line bar with a cookie badge, and a classic card. Each one can be customised when added or later: location, system/light/dark, colours (wallpaper Material You, current weather, or five fixed palettes, all WCAG AA checked) and background opacity from 100 % down to fully transparent.
- **Six home-screen widgets:** a minimal clock with date and current weather (transparent by default), a weather card that grows from 3×2 to 4×5 (hourly panel, then a daily list), a pill, a tilted pill, a one-line bar with a cookie badge, and a classic card. The widget picker shows real previews. Each one can be customised when added or later: location, system/light/dark, colours (wallpaper Material You, current weather, or five fixed palettes, all WCAG AA checked) and background opacity from 100 % down to fully transparent.
- **Coming next:** severe weather alerts (DWD / MeteoAlarm), precipitation radar map, card reordering, notifications.
<p align="center">
<img src="docs/screenshots/S01-clear-day.png" width="200" alt="Home, sunny">
<img src="docs/screenshots/S01-clear-night-dark.png" width="200" alt="Home, clear night, dark mode">
<img src="docs/screenshots/S01-rain-nowcast.png" width="200" alt="Rain with the next-hours precipitation card">
</p>
<p align="center">
<img src="docs/screenshots/S01-full-light.png" width="200" alt="Full forecast with detail tiles">
<img src="docs/screenshots/S04-day-dark.png" width="200" alt="Day detail">
<img src="docs/screenshots/S08-settings.png" width="200" alt="Settings">
</p>
<p align="center">
<img src="docs/screenshots/widgets.png" width="380" alt="Home-screen widgets: clock, weather card, pill, bar, classic, tilted pill">
</p>
<sub>Screenshots are rendered from a real Open-Meteo response (Berlin) by the app's screenshot tests; widgets are rendered on a Pixel with the wallpaper colours of that phone.</sub>
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).
- **Bedtime extras:** own sleep sounds (rain, waves, deep noise) with a sleep timer that fades out, and a sunrise option that slowly brightens the screen 15 minutes before the wake-up alarm.
- **Coming next:** widget style options like Wetter, screensaver/night clock.
<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
@@ -48,6 +92,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
```
### 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.
**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).
@@ -56,20 +110,22 @@ obtainium://app/%7B%22id%22%3A%22app.wetter%22%2C%22url%22%3A%22https%3A%2F%2Fgi
### 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 |
| --- | --- | --- |
| 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) |
| 2 | `replica-architect` | [Architecture](replica/architecture.md) and [schema](replica/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 |
| 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 |
| 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/wetter/architecture.md) and [schema](replica/wetter/schema.sql): native Kotlin, offline-first, no server, public APIs only |
| 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/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 |
**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:
- **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.
### Built with AI, steered by a human
@@ -87,19 +143,20 @@ 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).
```bash
cd wetter
cd wetter # or: cd uhr
./gradlew :app:assembleRelease # APK in app/build/outputs/apk/release/
./gradlew :app:testDebugUnitTest # unit + screenshot tests
./gradlew :app:recordRoborazziDebug # re-render replica/clone-screens/
./gradlew :app:recordRoborazziDebug # re-render replica/wetter/clone-screens/
```
Local release builds are signed with the debug key so they install over each other. Published releases will use a dedicated signing key.
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.
## 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)
uhr/ the clock and alarm app (Kotlin, Jetpack Compose, AlarmManager, Glance, Room)
```
## Credits
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 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 | – | city names offline via ICU exemplar cities |
| S07 Add city | 2026-10-17 | done | – | – |
| S08 Timer | 2026-10-17 | done | – | verified on device: 5 s timer rang in the background, stopped from the notification |
| S09 Stopwatch | 2026-10-17 | done | – | – |
| S10 Bedtime | 2026-10-17 | done | sunrise full-screen not confirmed on device | sleep sounds generated in-house; the sunrise needs the full-screen-intent permission, which Android 14+ does not grant sideloaded apps by default (the app asks) |
| S11 Ringing | 2026-10-17 | done | – | – |
| S12 Timer ringing | 2026-10-17 | done | – | shares the ringing screen |
| S13 Settings | 2026-10-17 | done | timer sound picker, home time zone | – |
| 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: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 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: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

+110
View File
@@ -0,0 +1,110 @@
// 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)
val sunriseNight = Color(0xFF0D0710)
val sunriseEmber = Color(0xFF5A1A10)
val sunriseDay = Color(0xFFFFD9A0)
val sunriseSun = Color(0xFFFFF3D6)
}
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)
+337
View File
@@ -0,0 +1,337 @@
{
"_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",
"sunrise-night": "#0d0710",
"sunrise-ember": "#5a1a10",
"sunrise-day": "#ffd9a0",
"sunrise-sun": "#fff3d6"
},
"pairs": [
[
"text",
"bg"
],
[
"text",
"card"
],
[
"text",
"card-high"
],
[
"text-muted",
"bg"
],
[
"text-muted",
"card"
],
[
"text-off",
"card"
],
[
"on-accent",
"accent"
],
[
"on-accent-container",
"accent-container"
],
[
"on-fab",
"fab"
],
[
"on-chip-on",
"chip-on"
],
[
"on-secondary-pill",
"secondary-pill"
],
[
"on-stop",
"stop"
],
[
"accent",
"card"
],
[
"danger",
"card"
],
[
"text",
"nav"
],
[
"text",
"nav-indicator"
],
[
"border-input",
"surface",
"ui"
],
[
"dark-text",
"dark-bg"
],
[
"dark-text",
"dark-card"
],
[
"dark-text",
"dark-card-high"
],
[
"dark-text-muted",
"dark-bg"
],
[
"dark-text-muted",
"dark-card"
],
[
"dark-text-off",
"dark-card"
],
[
"dark-on-accent",
"dark-accent"
],
[
"dark-on-accent-container",
"dark-accent-container"
],
[
"dark-on-fab",
"dark-fab"
],
[
"dark-on-chip-on",
"dark-chip-on"
],
[
"dark-on-secondary-pill",
"dark-secondary-pill"
],
[
"dark-on-stop",
"dark-stop"
],
[
"dark-accent",
"dark-card"
],
[
"dark-danger",
"dark-card"
],
[
"dark-text",
"dark-nav"
],
[
"dark-text",
"dark-nav-indicator"
],
[
"dark-border-input",
"dark-card",
"ui"
],
[
"ring-on",
"ring-bg"
],
[
"ring-on",
"ring-track"
],
[
"on-ring-thumb",
"ring-thumb",
"large"
],
[
"timer-progress",
"dark-card",
"ui"
],
[
"night-text",
"night-bg",
"large"
]
],
"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,yes,"long-press drag; verified on device, order persists"
Analog or digital clock style,clock,should,yes,yes,
Show seconds,clock,could,yes,yes,
Automatic home clock when travelling,clock,could,yes,no,
Timer keypad h m s,timer,must,yes,yes,
Run several timers at once,timer,must,yes,yes,
Timer ring progress +1:00 pause reset delete,timer,must,yes,yes,
Timer labels,timer,could,yes,yes,tap the title to rename
Timer runs in background with notification,timer,must,yes,yes,
Timer ringing screen,timer,must,yes,yes,
Timer sound and vibrate settings,settings,should,yes,partial,vibrate setting only; sound uses default
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,partial,scheduled 15 min before the bedtime wake alarm via full-screen notification; full-screen launch not confirmed on device (no keyguard during test)
Sleep sounds with timer,bedtime,could,yes,yes,"own generated sounds (rain, waves, brown noise), sleep timer with 20 s fade-out; verified playing on device"
Screensaver / bedside clock with night mode,display,could,yes,no,DreamService
Digital clock widget,widgets,must,yes,yes,
Analog clock widget,widgets,should,yes,yes,
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 yes long-press drag; verified on device, order persists
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 yes tap the title to rename
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 partial scheduled 15 min before the bedtime wake alarm via full-screen notification; full-screen launch not confirmed on device (no keyguard during test)
44 Sleep sounds with timer bedtime could yes yes own generated sounds (rain, waves, brown noise), sleep timer with 20 s fade-out; verified playing on device
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": {
"bg": "#f8f9ff",
"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

+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Build, sign and publish one app as a Gitea release that Obtainium can find.
# Usage: scripts/release.sh wetter (version comes from app/build.gradle.kts)
# Needs: .giteatoken in the repo root (git-ignored) and ~/.android/nogoogleapps-release.properties
set -euo pipefail
cd "$(dirname "$0")/.."
app="${1:?app folder, e.g. wetter}"
title="$(tr '[:lower:]' '[:upper:]' <<< "${app:0:1}")${app:1}"
version="$(sed -nE 's/.*versionName = "([^"]+)".*/\1/p' "$app/app/build.gradle.kts")"
tag="$app-v$version"
api="https://git.mrblake.cc/api/v1/repos/MrBlake/NoGooleApps"
token="$(tr -d ' \n' < .giteatoken)"
[ -f ~/.android/nogoogleapps-release.properties ] || { echo "release key missing" >&2; exit 1; }
(cd "$app" && ./gradlew -q :app:assembleRelease)
apk="$app/app/build/outputs/apk/release/app-release.apk"
"${ANDROID_HOME:-$HOME/Android/Sdk}"/build-tools/36.0.0/apksigner verify "$apk"
git -c credential.helper= push -q "https://MrBlake:$token@git.mrblake.cc/MrBlake/NoGooleApps.git" HEAD:main
body="$(printf '{"tag_name":"%s","target_commitish":"%s","name":"%s %s","prerelease":true}' "$tag" "$(git rev-parse HEAD)" "$title" "$version")"
id="$(curl -sf -X POST -H "Authorization: token $token" -H "Content-Type: application/json" -d "$body" "$api/releases" \
| python3 -I -c 'import sys,json; print(json.load(sys.stdin)["id"])')"
curl -sf -X POST -H "Authorization: token $token" \
-F "attachment=@$apk;type=application/vnd.android.package-archive" \
"$api/releases/$id/assets?name=$app-$version.apk" > /dev/null
echo "published $title $version ($tag)"
+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 = 4
versionName = "0.2.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,36 @@
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)
if (intent.getBooleanExtra("sunrise", false)) {
// through the real path: AlarmManager -> receiver -> full-screen notification, 20 s from now
Graph.repo.scheduler.scheduleSunrise(System.currentTimeMillis() + app.uhr.alarm.SunriseActivity.DURATION_MS + 20_000L)
finish(); return
}
if (intent.getBooleanExtra("cities", false)) {
runBlocking { app.uhr.data.Zones.search("").let { } ; listOf("Tokyo", "New_York", "London").forEach { c -> app.uhr.data.Zones.search(c.replace('_', ' ')).firstOrNull()?.let { Graph.repo.addCity(it) } } }
finish(); return
}
val timerS = intent.getIntExtra("timer_seconds", 0)
if (timerS > 0) {
val id = runBlocking { Graph.repo.startTimer(timerS * 1000L, "Testtimer") }
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()
}
}
+67
View File
@@ -0,0 +1,67 @@
<?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="" />
<activity android:name=".alarm.SunriseActivity" android:exported="false" android:excludeFromRecents="true"
android:launchMode="singleInstance" android:showWhenLocked="true" android:turnScreenOn="true" android:taskAffinity="" />
<service android:name=".alarm.SleepSoundService" android:exported="false" android:foregroundServiceType="mediaPlayback" />
<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,261 @@
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) }
override fun rename(id: Long, label: String) { scope.launch { repo.setTimerLabel(id, label) } }
}
}
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) } }, onReorder = { ids -> scope.launch { repo.reorderCities(ids) } })
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,58 @@
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(); repo.scheduleBedtime() }
ACTION_UPCOMING -> repo.alarm(id)?.let { a -> repo.instance(id)?.let { Notifications.upcoming(context, a, it.fireAt) } }
ACTION_DISMISS_NEXT -> repo.dismissNext(id)
ACTION_SNOOZE_DISMISS -> repo.finishAlarm(id)
ACTION_TIMER_PAUSE -> repo.pauseTimer(id)
ACTION_TIMER_ADD_MINUTE -> repo.addMinute(id)
ACTION_SUNRISE -> Notifications.sunrise(context, id)
ACTION_BEDTIME -> { Notifications.bedtime(context); repo.scheduleBedtime() }
}
}
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"
const val ACTION_SUNRISE = "app.uhr.SUNRISE"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
}
@@ -0,0 +1,180 @@
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
const val ID_SLEEP = 5
const val ID_SUNRISE = 6
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(),
)
}
/** Full-screen intent is the only way to bring the sunrise screen up from the background. */
fun sunrise(context: Context, fireAt: Long) {
if (!allowed(context)) return
val full = PendingIntent.getActivity(
context, 7, Intent(context, SunriseActivity::class.java).putExtra(SunriseActivity.EXTRA_FIRE_AT, fireAt)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
// an update of an existing notification does not fire its full-screen intent again
nm(context).cancel(ID_SUNRISE)
nm(context).notify(
ID_SUNRISE,
NotificationCompat.Builder(context, CH_RINGING).setSmallIcon(R.drawable.ic_stat_alarm)
.setContentTitle(context.getString(R.string.sunrise_running)).setSilent(true)
.setCategory(NotificationCompat.CATEGORY_ALARM).setPriority(NotificationCompat.PRIORITY_MAX)
.setFullScreenIntent(full, true).setContentIntent(full).setTimeoutAfter(SunriseActivity.DURATION_MS + 60_000)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC).build(),
)
}
fun ringing(context: Context, title: String, text: String, fullScreen: PendingIntent, stop: PendingIntent, snooze: PendingIntent?): Notification =
NotificationCompat.Builder(context, CH_RINGING).setSmallIcon(R.drawable.ic_stat_alarm)
.setContentTitle(title).setContentText(text)
.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,108 @@
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 scheduleSunrise(fireAt: Long) {
val at = fireAt - SunriseActivity.DURATION_MS
if (at > System.currentTimeMillis()) am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, sunriseIntent(fireAt))
}
fun cancelSunrise() = am.cancel(sunriseIntent(0))
/** One sunrise at a time (fixed request code); the wake-up time travels as an extra. */
private fun sunriseIntent(fireAt: Long) = PendingIntent.getBroadcast(
context, REQ_SUNRISE,
Intent(context, AlarmReceiver::class.java).setAction(AlarmReceiver.ACTION_SUNRISE).putExtra(AlarmReceiver.EXTRA_ID, fireAt),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
fun cancelBedtimeReminder() = am.cancel(broadcast(AlarmReceiver.ACTION_BEDTIME, 0, REQ_BEDTIME))
private fun ringIntent(id: Long) = broadcast(AlarmReceiver.ACTION_RING, id, REQ_RING)
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
private const val REQ_SUNRISE = 4_000_000
}
}
@@ -0,0 +1,109 @@
package app.uhr.alarm
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.net.Uri
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import app.uhr.MainActivity
import app.uhr.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
enum class SleepSound(val res: Int, val title: Int) {
RAIN(R.raw.sleep_rain, R.string.sound_rain),
WAVES(R.raw.sleep_waves, R.string.sound_waves),
BROWN(R.raw.sleep_brown, R.string.sound_brown),
}
data class SleepPlayback(val sound: SleepSound, val endsAt: Long)
/** Plays a looping sleep sound on the media stream and fades it out at the end of the sleep timer. */
class SleepSoundService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var player: MediaPlayer? = null
private var job: Job? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) { stop(); return START_NOT_STICKY }
val sound = SleepSound.entries.getOrNull(intent?.getIntExtra(EXTRA_SOUND, 0) ?: 0) ?: SleepSound.RAIN
val minutes = intent?.getIntExtra(EXTRA_MINUTES, 30) ?: 30
val endsAt = System.currentTimeMillis() + minutes * 60_000L
ServiceCompat.startForeground(this, Notifications.ID_SLEEP, notification(sound, endsAt), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
player?.release()
player = MediaPlayer().apply {
setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build())
setDataSource(this@SleepSoundService, Uri.parse("android.resource://$packageName/${sound.res}"))
isLooping = true
prepare()
setVolume(0f, 0f)
start()
}
state.value = SleepPlayback(sound, endsAt)
job?.cancel()
job = scope.launch {
fade(0f, 1f, 3_000)
delay((endsAt - System.currentTimeMillis() - FADE_OUT_MS).coerceAtLeast(0))
fade(1f, 0f, FADE_OUT_MS)
stop()
}
return START_NOT_STICKY
}
private suspend fun fade(from: Float, to: Float, ms: Long) {
val steps = (ms / 100).toInt().coerceAtLeast(1)
for (i in 1..steps) {
val v = from + (to - from) * i / steps
runCatching { player?.setVolume(v, v) }
delay(100)
}
}
private fun notification(sound: SleepSound, endsAt: Long) =
NotificationCompat.Builder(this, Notifications.CH_RUNNING).setSmallIcon(R.drawable.ic_stat_bedtime)
.setContentTitle(getString(sound.title)).setContentText(getString(R.string.sleep_sound_running))
.setUsesChronometer(true).setChronometerCountDown(true).setWhen(endsAt).setShowWhen(true)
.setOngoing(true).setSilent(true)
.setContentIntent(PendingIntent.getActivity(this, 5, Intent(this, MainActivity::class.java).putExtra(MainActivity.EXTRA_TAB, 4), PendingIntent.FLAG_IMMUTABLE))
.addAction(0, getString(R.string.stop), PendingIntent.getService(this, 6, Intent(this, SleepSoundService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_IMMUTABLE))
.build()
private fun stop() {
job?.cancel()
player?.runCatching { stop(); release() }; player = null
state.value = null
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() { player?.release(); state.value = null; scope.cancel(); super.onDestroy() }
companion object {
private const val ACTION_STOP = "stop"
private const val EXTRA_SOUND = "sound"
private const val EXTRA_MINUTES = "minutes"
private const val FADE_OUT_MS = 20_000L
private val state = MutableStateFlow<SleepPlayback?>(null)
val playing: StateFlow<SleepPlayback?> get() = state
fun start(context: Context, sound: SleepSound, minutes: Int) = context.startForegroundService(
Intent(context, SleepSoundService::class.java).putExtra(EXTRA_SOUND, sound.ordinal).putExtra(EXTRA_MINUTES, minutes),
)
fun stop(context: Context) = context.startService(Intent(context, SleepSoundService::class.java).setAction(ACTION_STOP))
}
}
@@ -0,0 +1,62 @@
package app.uhr.alarm
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.lerp
import app.uhr.ui.theme.WColor
import kotlinx.coroutines.delay
/**
* Sunrise alarm: in the last minutes before the wake-up alarm the screen turns on over the lock screen
* and brightens slowly from a dim red glow to warm daylight. Tap to dismiss; the alarm still rings.
*/
class SunriseActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setShowWhenLocked(true)
setTurnScreenOn(true)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
val endsAt = intent.getLongExtra(EXTRA_FIRE_AT, System.currentTimeMillis() + DURATION_MS)
val startedAt = endsAt - DURATION_MS
setContent {
val progress = remember { mutableFloatStateOf(0f) }
val ringing by RingingService.ringing.collectAsState()
LaunchedEffect(Unit) {
while (true) {
val p = ((System.currentTimeMillis() - startedAt).toFloat() / DURATION_MS).coerceIn(0f, 1f)
progress.floatValue = p
// the window brightness follows the colour, from almost off to full
window.attributes = window.attributes.apply { screenBrightness = 0.01f + 0.99f * p * p }
delay(500)
}
}
// the ringing screen takes over when the alarm starts
LaunchedEffect(ringing) { if (ringing != null) finish() }
val p = progress.floatValue
Canvas(Modifier.fillMaxSize().clickable { finish() }) {
val top = lerp(WColor.sunriseNight, WColor.sunriseDay, p)
val glow = lerp(WColor.sunriseEmber, WColor.sunriseSun, p)
drawRect(Brush.verticalGradient(listOf(top, glow)))
drawCircle(Brush.radialGradient(listOf(glow.copy(alpha = .9f), glow.copy(alpha = 0f)), Offset(size.width / 2, size.height), size.width * (0.4f + p)), size.width * (0.4f + p), Offset(size.width / 2, size.height))
}
}
}
companion object {
const val EXTRA_FIRE_AT = "fire_at"
const val DURATION_MS = 15 * 60_000L
}
}
@@ -0,0 +1,203 @@
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) }
val saved = dao.alarm(id)
saved?.let { scheduler.reschedule(it) }
updateClockWidgets(context)
id
}.also { id -> if (dao.alarm(id)?.isBedtimeWake == true) scheduleBedtime() }
suspend fun delete(alarm: Alarm) = lock.withLock {
scheduler.cancel(alarm.id)
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 bedtimeAlarmFireAt(): Long? {
val a = dao.bedtimeAlarm()?.takeIf { it.enabled } ?: return null
return dao.instance(a.id)?.takeIf { it.state == InstanceState.SCHEDULED }?.fireAt
}
suspend fun scheduleBedtime() {
val b = prefs.bedtime.first()
scheduler.cancelSunrise()
if (b.enabled && b.sunrise) bedtimeAlarmFireAt()?.let { scheduler.scheduleSunrise(it) }
scheduler.cancelBedtimeReminder()
if (!b.enabled || b.reminderMin < 0) return
val reminder = Alarm(hour = b.bedHour, minute = b.bedMinute, days = b.days)
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")
}
+160
View File
@@ -0,0 +1,160 @@
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,
val sunrise: Boolean = false,
)
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")
val sunrise = booleanPreferencesKey("sunrise")
}
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, sunrise = p[K.sunrise] ?: d.sunrise,
)
}
suspend fun setBedtime(b: Bedtime) {
context.store.edit { p ->
p[K.bedOn] = b.enabled; p[K.bedH] = b.bedHour; p[K.bedM] = b.bedMinute; p[K.wakeH] = b.wakeHour
p[K.wakeM] = b.wakeMinute; p[K.bedDays] = b.days; p[K.bedReminder] = b.reminderMin; p[K.sunrise] = b.sunrise
}
}
}
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,467 @@
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.animation.core.tween
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.ui.graphics.graphicsLayer
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.GraphicEq
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
/** One spring for everything that moves when a card opens, so size, content and neighbours stay in step. */
private val expandSpring = spring<Float>(dampingRatio = 0.9f, stiffness = 500f)
private fun androidx.compose.animation.core.SpringSpec<Float>.toIntOffset() =
spring(dampingRatio, stiffness, androidx.compose.ui.unit.IntOffset.VisibilityThreshold)
private val expandSpringSize = spring(0.9f, 500f, androidx.compose.ui.unit.IntSize.VisibilityThreshold)
/** 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(Modifier.animateItem(placementSpec = expandSpring.toIntOffset()), 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(
Modifier.animateItem(placementSpec = expandSpring.toIntOffset()), 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(
modifier: Modifier,
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)
.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)
},
) {
val rotation by androidx.compose.animation.core.animateFloatAsState(if (expanded) 180f else 0f, expandSpring, label = "chevron")
Row(Modifier.height(48.dp), verticalAlignment = Alignment.CenterVertically) {
androidx.compose.animation.Crossfade(expanded, Modifier.weight(1f), animationSpec = tween(180), label = "label") { open ->
Row(Modifier.fillMaxWidth().height(48.dp), verticalAlignment = Alignment.CenterVertically) {
if (open) 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(Icons.Rounded.ExpandMore, null, Modifier.size(20.dp).graphicsLayer { rotationZ = rotation }, 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,
enter = androidx.compose.animation.expandVertically(expandSpringSize, expandFrom = Alignment.Top) + androidx.compose.animation.fadeIn(tween(220, delayMillis = 60)),
exit = androidx.compose.animation.shrinkVertically(expandSpringSize, shrinkTowards = Alignment.Top) + androidx.compose.animation.fadeOut(tween(120)),
) {
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) }
androidx.compose.foundation.text.BasicTextField(
text, { text = it }, Modifier.weight(1f),
textStyle = WType.body.copy(color = MaterialTheme.colorScheme.onSurface),
cursorBrush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.primary),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { onDone(text.trim()) }),
decorationBox = { inner ->
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Outlined.Label, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(WSpace.s12))
Box {
if (text.isEmpty()) Text(stringResource(R.string.add_label), style = WType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
inner()
}
}
},
)
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. The Compose dial has no haptics; like the original we tick
* on every value the hand passes and give a firmer click when it settles after release.
*/
@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) }
val view = androidx.compose.ui.platform.LocalView.current
androidx.compose.runtime.LaunchedEffect(state) {
var last = state.hour to state.minute
androidx.compose.runtime.snapshotFlow { state.hour to state.minute }.collect { now ->
if (now != last) {
view.performHapticFeedback(android.view.HapticFeedbackConstants.CLOCK_TICK)
last = now
}
}
}
// the dial switches from hours to minutes when the hour settles: confirm that with a firmer click
androidx.compose.runtime.LaunchedEffect(state) {
var hourMode = state.selection == androidx.compose.material3.TimePickerSelectionMode.Hour
androidx.compose.runtime.snapshotFlow { state.selection }.collect { sel ->
val nowHour = sel == androidx.compose.material3.TimePickerSelectionMode.Hour
if (hourMode && !nowHour) view.performHapticFeedback(android.view.HapticFeedbackConstants.CONFIRM)
hourMode = nowHour
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.pick_time)) },
text = {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
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 = {
view.performHapticFeedback(android.view.HapticFeedbackConstants.CONFIRM)
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
}
// demo on the alarm stream, like the real ring: audible in silent mode and Do Not Disturb
val preview = remember { SoundPreview(context) }
var playing by remember { mutableStateOf<String?>("none") }
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { preview.stop() } }
fun play(uri: String?) {
if (playing == uri && preview.isPlaying) { preview.stop(); playing = "none"; return }
playing = uri
preview.play(uri)
}
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, modifier = Modifier.weight(1f))
if (playing == uri && uri != Alarm.SILENT) Icon(Icons.Rounded.GraphicEq, stringResource(R.string.playing), tint = MaterialTheme.colorScheme.primary)
}
}
}
},
confirmButton = { TextButton(onClick = { preview.stop(); onPick(selected) }) { Text(stringResource(android.R.string.ok)) } },
dismissButton = { TextButton(onClick = { preview.stop(); onDismiss() }) { Text(stringResource(android.R.string.cancel)) } },
)
}
/** Plays a sound for a few seconds on the alarm stream at the user's alarm volume, then fades out. */
class SoundPreview(private val context: android.content.Context) {
private var player: android.media.MediaPlayer? = null
private val handler = android.os.Handler(android.os.Looper.getMainLooper())
val isPlaying: Boolean get() = runCatching { player?.isPlaying == true }.getOrDefault(false)
fun play(uri: String?) {
stop()
val u = when (uri) {
Alarm.SILENT -> return
null -> Uri.parse("android.resource://${context.packageName}/${R.raw.alarm_default}")
else -> Uri.parse(uri)
}
player = runCatching {
android.media.MediaPlayer().apply {
setAudioAttributes(android.media.AudioAttributes.Builder().setUsage(android.media.AudioAttributes.USAGE_ALARM)
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION).build())
setDataSource(context, u)
isLooping = true
prepare()
start()
}
}.getOrNull()
// a demo, not an alarm: stop after a few seconds
handler.postDelayed({ stop() }, DEMO_MS)
}
fun stop() {
handler.removeCallbacksAndMessages(null)
player?.runCatching { stop(); release() }
player = null
}
private companion object { const val DEMO_MS = 8_000L }
}

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