Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62867fcd07 | ||
|
|
b87683a0b0 | ||
|
|
bc4cd8daf1 | ||
|
|
887299a3a7 | ||
|
|
c163a6d578 | ||
|
|
3c9535e1d5 | ||
|
|
64d664fbe9 |
@@ -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)).
|
||||
@@ -42,6 +43,31 @@ A weather app with the look and feel of Pixel Weather: an animated sky that chan
|
||||
|
||||
Requires Android 10 (API 29) or newer.
|
||||
|
||||
### Uhr
|
||||
|
||||
A clock and alarm app with the look and flow of the Pixel clock: alarms in expandable cards, world clock, several timers at once, stopwatch with laps, and a bedtime schedule.
|
||||
|
||||
- **Reliable alarms without Google.** Exact alarms via `AlarmManager.setAlarmClock` (exempt from Doze, shown in the status bar), rescheduled after reboot, time or time zone changes and app updates. Wall-clock based: 7:00 stays 7:00 across daylight saving and travel; the scheduling logic is unit-tested for DST gaps and zone changes.
|
||||
- **Ringing done right:** a full-screen screen over the lock screen with slide to snooze or stop, alarm-stream audio that gets louder gradually, vibration, automatic silence after a set time, and notifications for upcoming, snoozed and missed alarms. Volume keys can snooze or stop.
|
||||
- **No network permission at all.** World clock cities come from the system time zone database (names localised offline), sounds from the device, the app's own tone, or your own audio files.
|
||||
- **Also:** days and labels per alarm, pause until a date, skip the next ring, timers with ring progress and +1:00 that keep running in the background with a live countdown notification, stopwatch with fastest/slowest laps, analog or digital clock, wallpaper colours (Material You), three home-screen widgets (live digital clock, analog clock, next alarm).
|
||||
- **Coming next:** widget style options like Wetter, screensaver/night clock, sunrise alarm, sleep sounds, reordering world clocks.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/uhr/S01-alarms-dark.png" width="200" alt="Alarm list">
|
||||
<img src="docs/screenshots/uhr/S02-expanded-dark.png" width="200" alt="Alarm expanded">
|
||||
<img src="docs/screenshots/uhr/S11-ringing.png" width="200" alt="Ringing screen with slide to snooze or stop">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/uhr/S06-clock-dark.png" width="200" alt="World clock">
|
||||
<img src="docs/screenshots/uhr/S08-timers-dark.png" width="200" alt="Timers">
|
||||
<img src="docs/screenshots/uhr/S09-stopwatch-dark.png" width="200" alt="Stopwatch with laps">
|
||||
</p>
|
||||
|
||||
<sub>Screenshots rendered by the app's screenshot tests with the fallback palette; on Android 12+ the app uses your wallpaper colours.</sub>
|
||||
|
||||
Requires Android 10 (API 29) or newer.
|
||||
|
||||
## Install with Obtainium
|
||||
|
||||
[Obtainium](https://github.com/ImranR98/Obtainium) installs and updates apps straight from this repository's releases, with no store in between.
|
||||
@@ -65,6 +91,16 @@ If the button does not open Obtainium (some browsers block links to apps; the pa
|
||||
obtainium://app/%7B%22id%22%3A%22app.wetter%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Wetter%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.wetter%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EWetter%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Ewetter-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D
|
||||
```
|
||||
|
||||
### 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).
|
||||
@@ -73,20 +109,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
|
||||
@@ -104,10 +142,10 @@ Each commit says what changed and carries a `Co-Authored-By` line for the AI.
|
||||
Requirements: JDK 17 or newer and the Android SDK (platform 36).
|
||||
|
||||
```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/
|
||||
```
|
||||
|
||||
Release builds are signed with the project key from `~/.android/nogoogleapps-release.properties` (kept outside the repository); without it they fall back to the debug key for local testing. `scripts/release.sh <app>` builds, signs and publishes a release in the format Obtainium expects.
|
||||
@@ -115,8 +153,9 @@ Release builds are signed with the project key from `~/.android/nogoogleapps-rel
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -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.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Build log: Uhr
|
||||
|
||||
App in `uhr/`, same stack as `wetter/`. Screenshots: `./gradlew :app:recordRoborazziDebug` → `replica/uhr/clone-screens/` (fixed palette, not wallpaper colours).
|
||||
Device checks on a Pixel 10 Pro XL (Android 16) via a debug-only activity that saves a real alarm/timer through the repository.
|
||||
|
||||
| ID | date | status | missing | harder than expected |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| slice | 2026-10-17 | done | – | verified on device: exact alarm registered (RTC_WAKEUP, policy_permission), rang at 15:12:00 with alarm-stream audio, heads-up while unlocked, full-screen screen on tap, snooze moved it to +10 min, stop from the snoozed notification disabled the one-time alarm |
|
||||
| S01 Alarm list | 2026-10-17 | done | – | – |
|
||||
| S02 Expanded card | 2026-10-17 | done | – | label editing saves on dismiss |
|
||||
| S03 Time picker | 2026-10-17 | done | – | – |
|
||||
| S04 Sound picker | 2026-10-17 | done | – | own file not device-tested |
|
||||
| S05 Pause alarm | 2026-10-17 | done | – | – |
|
||||
| S06 Clock | 2026-10-17 | done | reorder cities | city names offline via ICU exemplar cities |
|
||||
| S07 Add city | 2026-10-17 | done | – | – |
|
||||
| S08 Timer | 2026-10-17 | done | label edit UI | verified on device: 5 s timer rang in the background, stopped from the notification |
|
||||
| S09 Stopwatch | 2026-10-17 | done | – | – |
|
||||
| S10 Bedtime | 2026-10-17 | partial | sunrise alarm, sleep sounds | – |
|
||||
| S11 Ringing | 2026-10-17 | done | – | – |
|
||||
| S12 Timer ringing | 2026-10-17 | done | – | shares the ringing screen |
|
||||
| S13 Settings | 2026-10-17 | done | timer sound picker, home time zone | – |
|
||||
| S14 Screensaver | – | not started | | |
|
||||
| S15 Notifications | 2026-10-17 | done | – | upcoming, snoozed, missed, timers (live countdown), stopwatch (live) |
|
||||
| S16 Widgets | 2026-10-17 | partial | style options, world clock widget, picker previews; **not checked on the launcher** | live clocks via TextClock/AnalogClock RemoteViews |
|
||||
|
||||
Not verified yet: alarm after reboot, after a time zone change, with battery saver, over the lock screen with the screen off (expected full screen there).
|
||||
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 143 KiB |
@@ -0,0 +1,106 @@
|
||||
// Generated from tokens.json by gen_theme.py. Do not edit by hand.
|
||||
package app.uhr.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
object WColor {
|
||||
val bg = Color(0xFFF8F9FF)
|
||||
val surface = Color(0xFFFFFFFF)
|
||||
val card = Color(0xFFEEF0F6)
|
||||
val cardHigh = Color(0xFFE2E5EC)
|
||||
val border = Color(0xFFC3C6CF)
|
||||
val borderInput = Color(0xFF73777F)
|
||||
val text = Color(0xFF191C20)
|
||||
val textMuted = Color(0xFF43474E)
|
||||
val textOff = Color(0xFF5C6068)
|
||||
val accent = Color(0xFF2F5EA8)
|
||||
val onAccent = Color(0xFFFFFFFF)
|
||||
val accentContainer = Color(0xFFD6E3FF)
|
||||
val onAccentContainer = Color(0xFF001B3E)
|
||||
val fab = Color(0xFFA8C7FA)
|
||||
val onFab = Color(0xFF0B2A55)
|
||||
val chipOn = Color(0xFF2F5EA8)
|
||||
val onChipOn = Color(0xFFFFFFFF)
|
||||
val secondaryPill = Color(0xFFDFE2EB)
|
||||
val onSecondaryPill = Color(0xFF191C20)
|
||||
val stop = Color(0xFFFFD8E4)
|
||||
val onStop = Color(0xFF4A1426)
|
||||
val danger = Color(0xFFBA1A1A)
|
||||
val nav = Color(0xFFECEEF4)
|
||||
val navIndicator = Color(0xFFD6E3FF)
|
||||
val darkBg = Color(0xFF111318)
|
||||
val darkSurface = Color(0xFF111318)
|
||||
val darkCard = Color(0xFF1D2024)
|
||||
val darkCardHigh = Color(0xFF272A2F)
|
||||
val darkBorder = Color(0xFF43474E)
|
||||
val darkBorderInput = Color(0xFF8D9199)
|
||||
val darkText = Color(0xFFE2E2E9)
|
||||
val darkTextMuted = Color(0xFFC3C6CF)
|
||||
val darkTextOff = Color(0xFF9A9EA6)
|
||||
val darkAccent = Color(0xFFA8C7FA)
|
||||
val darkOnAccent = Color(0xFF062E6F)
|
||||
val darkAccentContainer = Color(0xFF244A80)
|
||||
val darkOnAccentContainer = Color(0xFFD6E3FF)
|
||||
val darkFab = Color(0xFFA8C7FA)
|
||||
val darkOnFab = Color(0xFF062E6F)
|
||||
val darkChipOn = Color(0xFFA8C7FA)
|
||||
val darkOnChipOn = Color(0xFF062E6F)
|
||||
val darkSecondaryPill = Color(0xFF3A3D43)
|
||||
val darkOnSecondaryPill = Color(0xFFE2E2E9)
|
||||
val darkStop = Color(0xFFF2B8C9)
|
||||
val darkOnStop = Color(0xFF4A1426)
|
||||
val darkDanger = Color(0xFFFFB4AB)
|
||||
val darkNav = Color(0xFF1D2024)
|
||||
val darkNavIndicator = Color(0xFF3A3F47)
|
||||
val ringBg = Color(0xFF1E5A52)
|
||||
val ringOn = Color(0xFFB4F1E6)
|
||||
val ringTrack = Color(0xFF2B6E65)
|
||||
val ringThumb = Color(0xFF3F9A7F)
|
||||
val onRingThumb = Color(0xFFFFFFFF)
|
||||
val timerTrack = Color(0xFF3A3D43)
|
||||
val timerProgress = Color(0xFF7EC4A8)
|
||||
val nightText = Color(0xFFC4433A)
|
||||
val nightBg = Color(0xFF000000)
|
||||
}
|
||||
|
||||
object WType {
|
||||
val labelSm = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.W500)
|
||||
val label = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.W500)
|
||||
val bodySm = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.W400)
|
||||
val body = TextStyle(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.W400)
|
||||
val title = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.W400)
|
||||
val headline = TextStyle(fontSize = 28.sp, lineHeight = 36.sp, fontWeight = FontWeight.W400)
|
||||
val alarmTime = TextStyle(fontSize = 56.sp, lineHeight = 60.sp, fontWeight = FontWeight.W600)
|
||||
val alarmAmpm = TextStyle(fontSize = 20.sp, lineHeight = 24.sp, fontWeight = FontWeight.W600)
|
||||
val timerTime = TextStyle(fontSize = 44.sp, lineHeight = 48.sp, fontWeight = FontWeight.W400)
|
||||
val clockBig = TextStyle(fontSize = 88.sp, lineHeight = 92.sp, fontWeight = FontWeight.W400)
|
||||
val stopwatch = TextStyle(fontSize = 72.sp, lineHeight = 76.sp, fontWeight = FontWeight.W500)
|
||||
val ringing = TextStyle(fontSize = 104.sp, lineHeight = 108.sp, fontWeight = FontWeight.W400)
|
||||
}
|
||||
|
||||
object WSpace {
|
||||
val s0 = 0.dp
|
||||
val s2 = 2.dp
|
||||
val s4 = 4.dp
|
||||
val s8 = 8.dp
|
||||
val s12 = 12.dp
|
||||
val s16 = 16.dp
|
||||
val s20 = 20.dp
|
||||
val s24 = 24.dp
|
||||
val s32 = 32.dp
|
||||
val s48 = 48.dp
|
||||
}
|
||||
|
||||
object WRadius {
|
||||
val xs = 8.dp
|
||||
val sm = 12.dp
|
||||
val md = 16.dp
|
||||
val lg = 24.dp
|
||||
val card = 28.dp
|
||||
val pill = 999.dp
|
||||
val navIndicator = 16.dp
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
```
|
||||
@@ -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)
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"_about": "Uhr tokens. Roles read by eye from replica/uhr/screens/ref-01..07 (Pixel Clock, dark first). Own values, no brand colour copied; on Android 12+ dynamic colour (wallpaper) replaces the accent roles. Check: python3 ~/.claude/skills/replica-design/contrast.py replica/uhr/design/tokens.json",
|
||||
"color": {
|
||||
"bg": "#f8f9ff", "surface": "#ffffff", "card": "#eef0f6", "card-high": "#e2e5ec",
|
||||
"border": "#c3c6cf", "border-input": "#73777f",
|
||||
"text": "#191c20", "text-muted": "#43474e", "text-off": "#5c6068",
|
||||
"accent": "#2f5ea8", "on-accent": "#ffffff",
|
||||
"accent-container": "#d6e3ff", "on-accent-container": "#001b3e",
|
||||
"fab": "#a8c7fa", "on-fab": "#0b2a55",
|
||||
"chip-on": "#2f5ea8", "on-chip-on": "#ffffff",
|
||||
"secondary-pill": "#dfe2eb", "on-secondary-pill": "#191c20",
|
||||
"stop": "#ffd8e4", "on-stop": "#4a1426",
|
||||
"danger": "#ba1a1a",
|
||||
"nav": "#eceef4", "nav-indicator": "#d6e3ff",
|
||||
|
||||
"dark-bg": "#111318", "dark-surface": "#111318", "dark-card": "#1d2024", "dark-card-high": "#272a2f",
|
||||
"dark-border": "#43474e", "dark-border-input": "#8d9199",
|
||||
"dark-text": "#e2e2e9", "dark-text-muted": "#c3c6cf", "dark-text-off": "#9a9ea6",
|
||||
"dark-accent": "#a8c7fa", "dark-on-accent": "#062e6f",
|
||||
"dark-accent-container": "#244a80", "dark-on-accent-container": "#d6e3ff",
|
||||
"dark-fab": "#a8c7fa", "dark-on-fab": "#062e6f",
|
||||
"dark-chip-on": "#a8c7fa", "dark-on-chip-on": "#062e6f",
|
||||
"dark-secondary-pill": "#3a3d43", "dark-on-secondary-pill": "#e2e2e9",
|
||||
"dark-stop": "#f2b8c9", "dark-on-stop": "#4a1426",
|
||||
"dark-danger": "#ffb4ab",
|
||||
"dark-nav": "#1d2024", "dark-nav-indicator": "#3a3f47",
|
||||
|
||||
"ring-bg": "#1e5a52", "ring-on": "#b4f1e6", "ring-track": "#2b6e65", "ring-thumb": "#3f9a7f", "on-ring-thumb": "#ffffff",
|
||||
"timer-track": "#3a3d43", "timer-progress": "#7ec4a8",
|
||||
"night-text": "#c4433a", "night-bg": "#000000"
|
||||
},
|
||||
"pairs": [
|
||||
["text","bg"],["text","card"],["text","card-high"],["text-muted","bg"],["text-muted","card"],["text-off","card"],
|
||||
["on-accent","accent"],["on-accent-container","accent-container"],["on-fab","fab"],["on-chip-on","chip-on"],
|
||||
["on-secondary-pill","secondary-pill"],["on-stop","stop"],["accent","card"],["danger","card"],["text","nav"],["text","nav-indicator"],
|
||||
["border-input","surface","ui"],
|
||||
["dark-text","dark-bg"],["dark-text","dark-card"],["dark-text","dark-card-high"],["dark-text-muted","dark-bg"],["dark-text-muted","dark-card"],["dark-text-off","dark-card"],
|
||||
["dark-on-accent","dark-accent"],["dark-on-accent-container","dark-accent-container"],["dark-on-fab","dark-fab"],["dark-on-chip-on","dark-chip-on"],
|
||||
["dark-on-secondary-pill","dark-secondary-pill"],["dark-on-stop","dark-stop"],["dark-accent","dark-card"],["dark-danger","dark-card"],
|
||||
["dark-text","dark-nav"],["dark-text","dark-nav-indicator"],["dark-border-input","dark-card","ui"],
|
||||
["ring-on","ring-bg"],["ring-on","ring-track"],["on-ring-thumb","ring-thumb","large"],
|
||||
["timer-progress","dark-card","ui"],["night-text","night-bg","large"]
|
||||
],
|
||||
"font": {
|
||||
"sans": "Roboto Flex (OFL), system fallback",
|
||||
"display": "Roboto Flex, weight 500-600, tabular numbers, for alarm times",
|
||||
"clock": "Roboto Flex, weight 300-400 for big clocks and the ringing screen",
|
||||
"mono": "tabular figures (fontFeatureSettings tnum) instead of a mono font for the stopwatch"
|
||||
},
|
||||
"type": {
|
||||
"label-sm": {"size": 12, "line": 16, "weight": 500},
|
||||
"label": {"size": 14, "line": 20, "weight": 500},
|
||||
"body-sm": {"size": 14, "line": 20, "weight": 400},
|
||||
"body": {"size": 16, "line": 24, "weight": 400},
|
||||
"title": {"size": 22, "line": 28, "weight": 400},
|
||||
"headline": {"size": 28, "line": 36, "weight": 400},
|
||||
"alarm-time": {"size": 56, "line": 60, "weight": 600},
|
||||
"alarm-ampm": {"size": 20, "line": 24, "weight": 600},
|
||||
"timer-time": {"size": 44, "line": 48, "weight": 400},
|
||||
"clock-big": {"size": 88, "line": 92, "weight": 400},
|
||||
"stopwatch": {"size": 72, "line": 76, "weight": 500},
|
||||
"ringing": {"size": 104, "line": 108, "weight": 400}
|
||||
},
|
||||
"space": [0, 2, 4, 8, 12, 16, 20, 24, 32, 48],
|
||||
"radius": {"xs": 8, "sm": 12, "md": 16, "lg": 24, "card": 28, "pill": 999, "nav-indicator": 16},
|
||||
"shape": {
|
||||
"fab": "circle 96dp (large, centred above the nav bar)",
|
||||
"stopwatch-primary": "pill 72dp tall, full width",
|
||||
"timer-ring": "circle stroke 6dp",
|
||||
"day-chip": "circle 36dp"
|
||||
},
|
||||
"motion": {
|
||||
"card-expand": "spring dampingRatio 0.85 stiffness 380",
|
||||
"slide-act": "spring back dampingRatio 0.7",
|
||||
"fast": "120ms", "base": "250ms"
|
||||
},
|
||||
"layout": {"gutter": 16, "card-gap": 8, "nav-height": 80, "fab-bottom": 16, "card-padding": 20}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
feature,area,priority,original,clone,notes
|
||||
Alarm list with on/off switch and next-ring text,alarm,must,yes,yes,
|
||||
Add alarm with time picker (dial and keyboard),alarm,must,yes,yes,
|
||||
Expandable alarm card (edit in place),alarm,must,yes,yes,
|
||||
Repeat days chips,alarm,must,yes,yes,
|
||||
Alarm label,alarm,must,yes,yes,
|
||||
Delete alarm with undo,alarm,must,yes,yes,
|
||||
Vibrate toggle,alarm,must,yes,yes,
|
||||
Alarm sound picker with preview (system ringtones),alarm,must,yes,yes,
|
||||
Own audio file as alarm sound,alarm,should,yes,partial,picker works; file loss falls back to default; not device-tested
|
||||
Pause alarm until date,alarm,should,yes,yes,
|
||||
Skip next occurrence (dismiss early),alarm,should,yes,yes,from notification
|
||||
Exact scheduling that survives reboot time change and Doze,alarm,must,yes,yes,AlarmManager.setAlarmClock
|
||||
Full-screen ringing screen over lock screen,ringing,must,yes,yes,
|
||||
Slide control snooze/stop,ringing,must,yes,yes,
|
||||
Snooze length setting,settings,must,yes,yes,
|
||||
Silence after setting,settings,must,yes,yes,
|
||||
Gradually increase volume,settings,should,yes,yes,
|
||||
Alarm volume setting,settings,should,yes,yes,
|
||||
Volume buttons behaviour (snooze/stop/volume/none),settings,should,yes,yes,
|
||||
Start week on,settings,could,yes,yes,
|
||||
Upcoming alarm notification with dismiss now,notifications,should,yes,yes,
|
||||
Missed alarm notification,notifications,should,yes,yes,
|
||||
Snoozed notification with dismiss,notifications,must,yes,yes,
|
||||
World clock: local time and date,clock,must,yes,yes,
|
||||
World clock: add city via search,clock,must,yes,yes,
|
||||
World clock: reorder and swipe to delete,clock,should,yes,partial,swipe to delete only
|
||||
Analog or digital clock style,clock,should,yes,yes,
|
||||
Show seconds,clock,could,yes,yes,
|
||||
Automatic home clock when travelling,clock,could,yes,no,
|
||||
Timer keypad h m s,timer,must,yes,yes,
|
||||
Run several timers at once,timer,must,yes,yes,
|
||||
Timer ring progress +1:00 pause reset delete,timer,must,yes,yes,
|
||||
Timer labels,timer,could,yes,partial,stored and shown; no UI to edit yet
|
||||
Timer runs in background with notification,timer,must,yes,yes,
|
||||
Timer ringing screen,timer,must,yes,yes,
|
||||
Timer sound and vibrate settings,settings,should,yes,partial,vibrate setting only; sound uses default
|
||||
Stopwatch start stop reset,stopwatch,must,yes,yes,
|
||||
Stopwatch laps,stopwatch,must,yes,yes,
|
||||
Stopwatch running notification,stopwatch,should,yes,yes,
|
||||
Bedtime schedule with wake-up alarm,bedtime,should,yes,yes,
|
||||
Bedtime reminder notification,bedtime,should,yes,yes,
|
||||
Sunrise alarm (brightening screen),bedtime,could,yes,no,
|
||||
Sleep sounds with timer,bedtime,could,yes,no,own CC0 sounds
|
||||
Screensaver / bedside clock with night mode,display,could,yes,no,DreamService
|
||||
Digital clock widget,widgets,must,yes,yes,
|
||||
Analog clock widget,widgets,should,yes,yes,
|
||||
World clock widget,widgets,could,yes,no,
|
||||
Next alarm widget,widgets,should,yes,yes,
|
||||
Widget style options (theme colours opacity),widgets,should,yes,no,like Wetter
|
||||
Theme system/light/dark and dynamic colour,display,must,yes,yes,
|
||||
Expressive design (bottom bar pill cards shapes),display,must,yes,yes,
|
||||
Streaming alarm sounds (YouTube Music Spotify Calm),alarm,could,yes,skip,partner accounts
|
||||
Google Assistant Routine,alarm,could,yes,skip,Google service
|
||||
Bedtime mode greyscale and DND,bedtime,could,yes,skip,Digital Wellbeing privileges
|
||||
Sleep tracking cough and snore,bedtime,could,yes,skip,system privileges + Pixel hardware
|
||||
Calendar upcoming events card,bedtime,could,yes,skip,Google Calendar
|
||||
|
@@ -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.
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 442 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
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 |
@@ -0,0 +1,6 @@
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
.idea/
|
||||
*.iml
|
||||
.kotlin/
|
||||
@@ -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 = 2
|
||||
versionName = "0.1.1"
|
||||
}
|
||||
|
||||
// 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")) }
|
||||
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity android:name=".DebugAlarmActivity" android:exported="true" android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,27 @@
|
||||
package app.uhr
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import app.uhr.data.Alarm
|
||||
import app.uhr.data.Graph
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.time.LocalTime
|
||||
|
||||
/** Debug only: `--ei in_seconds 70` saves a real alarm that far ahead, through the normal repository path. */
|
||||
class DebugAlarmActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Graph.init(this)
|
||||
val timerS = intent.getIntExtra("timer_seconds", 0)
|
||||
if (timerS > 0) {
|
||||
val id = runBlocking { Graph.repo.startTimer(timerS * 1000L, "Testtimer") }
|
||||
Log.i("DebugAlarm", "timer $id for ${timerS}s"); finish(); return
|
||||
}
|
||||
val t = LocalTime.now().plusSeconds(intent.getIntExtra("in_seconds", 70).toLong())
|
||||
val id = runBlocking { Graph.repo.save(Alarm(hour = t.hour, minute = t.minute, label = intent.getStringExtra("label") ?: "Test")) }
|
||||
val inst = runBlocking { Graph.repo.instance(id) }
|
||||
Log.i("DebugAlarm", "alarm $id at ${t.hour}:${t.minute} fires ${inst?.fireAt}")
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- no INTERNET, no location: nothing leaves the device -->
|
||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:name=".UhrApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Material.NoActionBar">
|
||||
|
||||
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".alarm.RingingActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleInstance"
|
||||
android:showWhenLocked="true"
|
||||
android:turnScreenOn="true"
|
||||
android:taskAffinity="" />
|
||||
|
||||
<service android:name=".alarm.RingingService" android:exported="false" android:foregroundServiceType="mediaPlayback" />
|
||||
|
||||
<receiver android:name=".alarm.AlarmReceiver" android:exported="false" android:directBootAware="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.TIME_SET" />
|
||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name=".widget.DigitalClockReceiver" android:exported="true" android:label="@string/widget_digital_name">
|
||||
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_digital" />
|
||||
</receiver>
|
||||
<receiver android:name=".widget.AnalogClockReceiver" android:exported="true" android:label="@string/widget_analog_name">
|
||||
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_analog" />
|
||||
</receiver>
|
||||
<receiver android:name=".widget.NextAlarmReceiver" android:exported="true" android:label="@string/widget_next_name">
|
||||
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_next" />
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,260 @@
|
||||
package app.uhr
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationManager
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings as SystemSettings
|
||||
import android.text.format.DateFormat
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Alarm
|
||||
import androidx.compose.material.icons.outlined.Bedtime
|
||||
import androidx.compose.material.icons.outlined.HourglassEmpty
|
||||
import androidx.compose.material.icons.outlined.Schedule
|
||||
import androidx.compose.material.icons.outlined.Timer
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.MoreVert
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.uhr.alarm.RingingService
|
||||
import app.uhr.data.Alarm
|
||||
import app.uhr.data.Graph
|
||||
import app.uhr.data.Settings
|
||||
import app.uhr.ui.components.Fmt
|
||||
import app.uhr.ui.screens.AddCityDialog
|
||||
import app.uhr.ui.screens.AlarmActions
|
||||
import app.uhr.ui.screens.AlarmScreen
|
||||
import app.uhr.ui.screens.BedtimeScreen
|
||||
import app.uhr.ui.screens.ClockScreen
|
||||
import app.uhr.ui.screens.SettingsScreen
|
||||
import app.uhr.ui.screens.StopwatchScreen
|
||||
import app.uhr.ui.screens.TimeDialog
|
||||
import app.uhr.ui.screens.TimerActions
|
||||
import app.uhr.ui.screens.TimerScreen
|
||||
import app.uhr.ui.screens.digitsToMs
|
||||
import app.uhr.ui.theme.ClockTheme
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalTime
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private var tabFromIntent = mutableIntStateOf(-1)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
Graph.init(this)
|
||||
Fmt.is24h = DateFormat.is24HourFormat(this)
|
||||
tabFromIntent.intValue = intent.getIntExtra(EXTRA_TAB, -1)
|
||||
setContent {
|
||||
val settings by Graph.repo.prefs.settings.collectAsState(initial = Settings())
|
||||
ClockTheme(settings.theme) { ClockApp(settings, tabFromIntent.intValue) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
tabFromIntent.intValue = intent.getIntExtra(EXTRA_TAB, -1)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
Fmt.is24h = DateFormat.is24HourFormat(this)
|
||||
}
|
||||
|
||||
companion object { const val EXTRA_TAB = "tab" }
|
||||
}
|
||||
|
||||
private data class Tab(val title: Int, val icon: ImageVector)
|
||||
|
||||
private val TABS = listOf(
|
||||
Tab(R.string.tab_alarm, Icons.Outlined.Alarm),
|
||||
Tab(R.string.tab_clock, Icons.Outlined.Schedule),
|
||||
Tab(R.string.tab_timer, Icons.Outlined.HourglassEmpty),
|
||||
Tab(R.string.tab_stopwatch, Icons.Outlined.Timer),
|
||||
Tab(R.string.tab_bedtime, Icons.Outlined.Bedtime),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ClockApp(settings: Settings, requestedTab: Int) {
|
||||
val repo = Graph.repo
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
var tab by rememberSaveable { mutableIntStateOf(0) }
|
||||
LaunchedEffect(requestedTab) { if (requestedTab in TABS.indices) tab = requestedTab }
|
||||
var showSettings by rememberSaveable { mutableStateOf(false) }
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
var addAlarm by remember { mutableStateOf(false) }
|
||||
var addCity by remember { mutableStateOf(false) }
|
||||
var keypad by rememberSaveable { mutableStateOf(false) }
|
||||
var digits by rememberSaveable { mutableStateOf("") }
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
|
||||
val alarms by repo.alarms.collectAsState(initial = emptyList())
|
||||
val timers by repo.timers.collectAsState(initial = emptyList())
|
||||
val cities by repo.cities.collectAsState(initial = emptyList())
|
||||
val next by repo.nextInstance.collectAsState(initial = null)
|
||||
val sw by repo.stopwatch.collectAsState(initial = app.uhr.data.Stopwatch())
|
||||
val bedtime by repo.bedtime.collectAsState(initial = app.uhr.data.Bedtime())
|
||||
|
||||
PermissionGate()
|
||||
|
||||
if (showSettings) {
|
||||
BackHandler { showSettings = false }
|
||||
SettingsScreen(settings, { t -> scope.launch { repo.prefs.update(t) } }, onBack = { showSettings = false })
|
||||
return
|
||||
}
|
||||
|
||||
val alarmActions = remember {
|
||||
object : AlarmActions {
|
||||
override fun save(alarm: Alarm) { scope.launch { repo.save(alarm) } }
|
||||
override fun delete(alarm: Alarm) { scope.launch { repo.delete(alarm) } }
|
||||
override fun restore(alarm: Alarm) { scope.launch { repo.restore(alarm) } }
|
||||
override fun dismissNext(alarm: Alarm) { scope.launch { repo.dismissNext(alarm.id) } }
|
||||
}
|
||||
}
|
||||
val timerActions = remember {
|
||||
object : TimerActions {
|
||||
override fun start(durationMs: Long) { scope.launch { repo.startTimer(durationMs) } }
|
||||
override fun pause(id: Long) { scope.launch { repo.pauseTimer(id) } }
|
||||
override fun resume(id: Long) { scope.launch { repo.resumeTimer(id) } }
|
||||
override fun addMinute(id: Long) { scope.launch { repo.addMinute(id) } }
|
||||
override fun reset(id: Long) { scope.launch { repo.resetTimer(id) } }
|
||||
override fun delete(id: Long) { scope.launch { repo.deleteTimer(id) } }
|
||||
override fun stopRinging() { RingingService.stop(context) }
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(TABS[tab].title)) },
|
||||
actions = {
|
||||
IconButton(onClick = { menu = true }) { Icon(Icons.Rounded.MoreVert, stringResource(R.string.more)) }
|
||||
DropdownMenu(menu, { menu = false }) {
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.settings)) }, onClick = { menu = false; showSettings = true })
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
TABS.forEachIndexed { i, t ->
|
||||
NavigationBarItem(selected = tab == i, onClick = { tab = i }, icon = { Icon(t.icon, null) }, label = { Text(stringResource(t.title)) })
|
||||
}
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
val fab: (() -> Unit)? = when (tab) {
|
||||
0 -> ({ addAlarm = true })
|
||||
1 -> ({ addCity = true })
|
||||
2 -> if (timers.isEmpty() || keypad) {
|
||||
if (digits.isNotEmpty()) ({ timerActions.start(digitsToMs(digits)); digits = ""; keypad = false }) else null
|
||||
} else ({ keypad = true })
|
||||
else -> null
|
||||
}
|
||||
if (fab != null) {
|
||||
val icon = if (tab == 2 && (timers.isEmpty() || keypad)) Icons.Rounded.PlayArrow else Icons.Rounded.Add
|
||||
FloatingActionButton(onClick = fab, shape = CircleShape, modifier = Modifier.size(96.dp),
|
||||
containerColor = app.uhr.ui.theme.fabColors().first, contentColor = app.uhr.ui.theme.fabColors().second) {
|
||||
Icon(icon, stringResource(R.string.add), Modifier.size(36.dp))
|
||||
}
|
||||
}
|
||||
},
|
||||
floatingActionButtonPosition = androidx.compose.material3.FabPosition.Center,
|
||||
snackbarHost = { SnackbarHost(snackbar) },
|
||||
) { pad ->
|
||||
when (tab) {
|
||||
0 -> AlarmScreen(alarms, settings, alarmActions, snackbar, pad)
|
||||
1 -> ClockScreen(settings, cities, next, pad, onDeleteCity = { c -> scope.launch { repo.deleteCity(c.id) } })
|
||||
2 -> {
|
||||
if (keypad && timers.isNotEmpty()) BackHandler { keypad = false; digits = "" }
|
||||
TimerScreen(timers, keypad, digits, { digits = it }, timerActions, pad)
|
||||
}
|
||||
3 -> StopwatchScreen(sw, { scope.launch { repo.stopwatchToggle() } }, { scope.launch { repo.stopwatchLap() } }, { scope.launch { repo.stopwatchReset() } }, pad)
|
||||
4 -> BedtimeScreen(bedtime, settings, { b -> scope.launch { repo.saveBedtime(b) } }, pad)
|
||||
}
|
||||
}
|
||||
|
||||
if (addAlarm) {
|
||||
val t = LocalTime.now().plusHours(1).withMinute(0)
|
||||
TimeDialog(t.hour, t.minute, { addAlarm = false }) { h, m ->
|
||||
addAlarm = false
|
||||
scope.launch {
|
||||
val id = repo.save(Alarm(hour = h, minute = m))
|
||||
repo.instance(id)?.let { snackbar.showSnackbar(context.getString(R.string.alarm_set_in, Fmt.until(context.resources, it.fireAt))) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (addCity) AddCityDialog({ addCity = false }) { hit -> addCity = false; scope.launch { repo.addCity(hit) } }
|
||||
}
|
||||
|
||||
/** Notifications and full-screen ringing are what make an alarm app work: ask once, explain if missing. */
|
||||
@Composable
|
||||
private fun PermissionGate() {
|
||||
val context = LocalContext.current
|
||||
var asked by rememberSaveable { mutableStateOf(false) }
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
LaunchedEffect(Unit) {
|
||||
if (!asked && Build.VERSION.SDK_INT >= 33) { asked = true; launcher.launch(Manifest.permission.POST_NOTIFICATIONS) }
|
||||
}
|
||||
val nm = context.getSystemService(NotificationManager::class.java)
|
||||
var hide by rememberSaveable { mutableStateOf(false) }
|
||||
if (Build.VERSION.SDK_INT >= 34 && !nm.canUseFullScreenIntent() && !hide) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { hide = true },
|
||||
title = { Text(stringResource(R.string.fsi_title)) },
|
||||
text = { Text(stringResource(R.string.fsi_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
hide = true
|
||||
context.startActivity(Intent(SystemSettings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, android.net.Uri.parse("package:${context.packageName}")))
|
||||
}) { Text(stringResource(R.string.open_settings)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { hide = true }) { Text(stringResource(R.string.later)) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package app.uhr
|
||||
|
||||
import android.app.Application
|
||||
import app.uhr.alarm.Notifications
|
||||
import app.uhr.data.Graph
|
||||
|
||||
class UhrApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Graph.init(this)
|
||||
Notifications.createChannels(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package app.uhr.alarm
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import app.uhr.data.Graph
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Entry point for every scheduled event and every notification action. */
|
||||
class AlarmReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Graph.init(context)
|
||||
val id = intent.getLongExtra(EXTRA_ID, -1)
|
||||
when (intent.action) {
|
||||
// ringing must start while the receiver is alive: foreground service right away
|
||||
ACTION_RING -> RingingService.startAlarm(context, id)
|
||||
ACTION_TIMER -> RingingService.startTimer(context, id)
|
||||
else -> {
|
||||
val pending = goAsync()
|
||||
scope.launch {
|
||||
try { handle(context, intent.action, id) } finally { pending.finish() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handle(context: Context, action: String?, id: Long) {
|
||||
val repo = Graph.repo
|
||||
when (action) {
|
||||
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_LOCKED_BOOT_COMPLETED, Intent.ACTION_TIME_CHANGED, "android.intent.action.TIME_SET",
|
||||
Intent.ACTION_TIMEZONE_CHANGED, Intent.ACTION_MY_PACKAGE_REPLACED -> repo.scheduler.rescheduleAll()
|
||||
ACTION_UPCOMING -> repo.alarm(id)?.let { a -> repo.instance(id)?.let { Notifications.upcoming(context, a, it.fireAt) } }
|
||||
ACTION_DISMISS_NEXT -> repo.dismissNext(id)
|
||||
ACTION_SNOOZE_DISMISS -> repo.finishAlarm(id)
|
||||
ACTION_TIMER_PAUSE -> repo.pauseTimer(id)
|
||||
ACTION_TIMER_ADD_MINUTE -> repo.addMinute(id)
|
||||
ACTION_BEDTIME -> { Notifications.bedtime(context); repo.scheduleBedtime() }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_ID = "id"
|
||||
const val ACTION_RING = "app.uhr.RING"
|
||||
const val ACTION_UPCOMING = "app.uhr.UPCOMING"
|
||||
const val ACTION_DISMISS_NEXT = "app.uhr.DISMISS_NEXT"
|
||||
const val ACTION_SNOOZE_DISMISS = "app.uhr.SNOOZE_DISMISS"
|
||||
const val ACTION_TIMER = "app.uhr.TIMER"
|
||||
const val ACTION_TIMER_PAUSE = "app.uhr.TIMER_PAUSE"
|
||||
const val ACTION_TIMER_ADD_MINUTE = "app.uhr.TIMER_ADD_MINUTE"
|
||||
const val ACTION_BEDTIME = "app.uhr.BEDTIME"
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package app.uhr.alarm
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.SystemClock
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import app.uhr.MainActivity
|
||||
import app.uhr.R
|
||||
import app.uhr.data.Alarm
|
||||
import app.uhr.data.Stopwatch
|
||||
import app.uhr.data.StopwatchState
|
||||
import app.uhr.data.Timer
|
||||
import app.uhr.data.TimerState
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
object Notifications {
|
||||
const val CH_RINGING = "ringing"
|
||||
const val CH_UPCOMING = "upcoming"
|
||||
const val CH_MISSED = "missed"
|
||||
const val CH_RUNNING = "running"
|
||||
const val CH_BEDTIME = "bedtime"
|
||||
|
||||
const val ID_TIMERS = 1
|
||||
const val ID_STOPWATCH = 2
|
||||
const val ID_RINGING = 3
|
||||
const val ID_BEDTIME = 4
|
||||
private const val ALARM_BASE = 1000
|
||||
|
||||
fun createChannels(context: Context) {
|
||||
val nm = context.getSystemService(NotificationManager::class.java)
|
||||
nm.createNotificationChannels(
|
||||
listOf(
|
||||
NotificationChannel(CH_RINGING, context.getString(R.string.ch_ringing), NotificationManager.IMPORTANCE_HIGH).apply {
|
||||
setSound(null, null); enableVibration(false); setBypassDnd(true)
|
||||
},
|
||||
NotificationChannel(CH_UPCOMING, context.getString(R.string.ch_upcoming), NotificationManager.IMPORTANCE_LOW),
|
||||
NotificationChannel(CH_MISSED, context.getString(R.string.ch_missed), NotificationManager.IMPORTANCE_DEFAULT),
|
||||
NotificationChannel(CH_RUNNING, context.getString(R.string.ch_running), NotificationManager.IMPORTANCE_LOW),
|
||||
NotificationChannel(CH_BEDTIME, context.getString(R.string.ch_bedtime), NotificationManager.IMPORTANCE_DEFAULT),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun allowed(context: Context) =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private fun nm(context: Context) = context.getSystemService(NotificationManager::class.java)
|
||||
|
||||
private fun open(context: Context, tab: Int = 0) = PendingIntent.getActivity(
|
||||
context, 100 + tab, Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_TAB, tab),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
private fun action(context: Context, action: String, id: Long) = PendingIntent.getBroadcast(
|
||||
context, (action.hashCode() * 31 + id).toInt(),
|
||||
Intent(context, AlarmReceiver::class.java).setAction(action).putExtra(AlarmReceiver.EXTRA_ID, id),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
private fun time(at: Long) = DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(at))
|
||||
private fun title(context: Context, a: Alarm) = a.label.ifBlank { context.getString(R.string.alarm) }
|
||||
|
||||
fun upcoming(context: Context, a: Alarm, fireAt: Long) {
|
||||
if (!allowed(context)) return
|
||||
nm(context).notify(
|
||||
(ALARM_BASE + a.id).toInt(),
|
||||
NotificationCompat.Builder(context, CH_UPCOMING).setSmallIcon(R.drawable.ic_stat_alarm)
|
||||
.setContentTitle(context.getString(R.string.notif_upcoming, time(fireAt)))
|
||||
.setContentText(title(context, a)).setContentIntent(open(context))
|
||||
.addAction(0, context.getString(R.string.dismiss_now), action(context, AlarmReceiver.ACTION_DISMISS_NEXT, a.id))
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER).build(),
|
||||
)
|
||||
}
|
||||
|
||||
fun snoozed(context: Context, a: Alarm, until: Long) {
|
||||
if (!allowed(context)) return
|
||||
nm(context).notify(
|
||||
(ALARM_BASE + a.id).toInt(),
|
||||
NotificationCompat.Builder(context, CH_UPCOMING).setSmallIcon(R.drawable.ic_stat_alarm)
|
||||
.setContentTitle(context.getString(R.string.notif_snoozed, time(until)))
|
||||
.setContentText(title(context, a)).setContentIntent(open(context)).setOngoing(true)
|
||||
.addAction(0, context.getString(R.string.stop), action(context, AlarmReceiver.ACTION_SNOOZE_DISMISS, a.id))
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
fun missed(context: Context, a: Alarm) {
|
||||
if (!allowed(context)) return
|
||||
nm(context).notify(
|
||||
(ALARM_BASE + a.id).toInt(),
|
||||
NotificationCompat.Builder(context, CH_MISSED).setSmallIcon(R.drawable.ic_stat_alarm)
|
||||
.setContentTitle(context.getString(R.string.notif_missed))
|
||||
.setContentText("${title(context, a)} · ${String.format("%d:%02d", a.hour, a.minute)}")
|
||||
.setContentIntent(open(context)).setAutoCancel(true).build(),
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelAlarm(context: Context, id: Long) = nm(context).cancel((ALARM_BASE + id).toInt())
|
||||
|
||||
/** One notification for all running/paused timers; the soonest one counts down live. */
|
||||
fun timers(context: Context, timers: List<Timer>) {
|
||||
val active = timers.filter { it.state != TimerState.RINGING }
|
||||
if (active.isEmpty() || !allowed(context)) { nm(context).cancel(ID_TIMERS); return }
|
||||
val next = active.filter { it.state == TimerState.RUNNING }.minByOrNull { it.endAt ?: Long.MAX_VALUE } ?: active.first()
|
||||
val b = NotificationCompat.Builder(context, CH_RUNNING).setSmallIcon(R.drawable.ic_stat_timer)
|
||||
.setContentTitle(next.label.ifBlank { context.getString(R.string.timer) })
|
||||
.setContentIntent(open(context, 2)).setOngoing(true).setOnlyAlertOnce(true).setSilent(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_STOPWATCH)
|
||||
if (next.state == TimerState.RUNNING) {
|
||||
b.setUsesChronometer(true).setChronometerCountDown(true).setWhen(next.endAt ?: 0).setShowWhen(true)
|
||||
b.addAction(0, context.getString(R.string.pause), action(context, AlarmReceiver.ACTION_TIMER_PAUSE, next.id))
|
||||
} else {
|
||||
b.setContentText(context.getString(R.string.paused))
|
||||
}
|
||||
b.addAction(0, context.getString(R.string.plus_minute), action(context, AlarmReceiver.ACTION_TIMER_ADD_MINUTE, next.id))
|
||||
if (active.size > 1) b.setSubText(context.getString(R.string.n_timers, active.size))
|
||||
nm(context).notify(ID_TIMERS, b.build())
|
||||
}
|
||||
|
||||
fun stopwatch(context: Context, s: Stopwatch) {
|
||||
if (s.state != StopwatchState.RUNNING || !allowed(context)) { nm(context).cancel(ID_STOPWATCH); return }
|
||||
// chronometer counts from a wall-clock base: translate the elapsedRealtime start
|
||||
val base = System.currentTimeMillis() - (SystemClock.elapsedRealtime() - s.startedAt) - s.accumulatedMs
|
||||
nm(context).notify(
|
||||
ID_STOPWATCH,
|
||||
NotificationCompat.Builder(context, CH_RUNNING).setSmallIcon(R.drawable.ic_stat_timer)
|
||||
.setContentTitle(context.getString(R.string.stopwatch)).setUsesChronometer(true).setWhen(base).setShowWhen(true)
|
||||
.setContentIntent(open(context, 3)).setOngoing(true).setSilent(true).build(),
|
||||
)
|
||||
}
|
||||
|
||||
fun bedtime(context: Context) {
|
||||
if (!allowed(context)) return
|
||||
nm(context).notify(
|
||||
ID_BEDTIME,
|
||||
NotificationCompat.Builder(context, CH_BEDTIME).setSmallIcon(R.drawable.ic_stat_bedtime)
|
||||
.setContentTitle(context.getString(R.string.notif_bedtime)).setContentText(context.getString(R.string.notif_bedtime_text))
|
||||
.setContentIntent(open(context, 4)).setAutoCancel(true).build(),
|
||||
)
|
||||
}
|
||||
|
||||
fun ringing(context: Context, title: String, text: String, fullScreen: PendingIntent, stop: PendingIntent, snooze: PendingIntent?): Notification =
|
||||
NotificationCompat.Builder(context, CH_RINGING).setSmallIcon(R.drawable.ic_stat_alarm)
|
||||
.setContentTitle(title).setContentText(text)
|
||||
.setCategory(NotificationCompat.CATEGORY_ALARM).setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
.setFullScreenIntent(fullScreen, true).setContentIntent(fullScreen).setOngoing(true)
|
||||
.apply { if (snooze != null) addAction(0, context.getString(R.string.snooze), snooze) }
|
||||
.addAction(0, context.getString(R.string.stop), stop)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC).build()
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package app.uhr.alarm
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Alarm
|
||||
import androidx.compose.material.icons.rounded.HourglassBottom
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.uhr.R
|
||||
import app.uhr.data.Graph
|
||||
import app.uhr.data.VolumeButtons
|
||||
import app.uhr.ui.components.Fmt
|
||||
import app.uhr.ui.theme.WType
|
||||
import app.uhr.ui.theme.WColor
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Full-screen ringing UI, shown over the lock screen. */
|
||||
class RingingActivity : ComponentActivity() {
|
||||
private var volumeButtons = VolumeButtons.VOLUME
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setShowWhenLocked(true)
|
||||
setTurnScreenOn(true)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
enableEdgeToEdge()
|
||||
Graph.init(this)
|
||||
setContent {
|
||||
val ring by RingingService.ringing.collectAsState()
|
||||
LaunchedEffect(Unit) { volumeButtons = Graph.repo.prefs.settingsOnce().volumeButtons }
|
||||
LaunchedEffect(ring) { if (ring == null) { delay(300); if (RingingService.ringing.value == null) finish() } }
|
||||
ring?.let { RingingScreen(it, onSnooze = { RingingService.snooze(this) }, onStop = { RingingService.stop(this) }) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if (keyCode != KeyEvent.KEYCODE_VOLUME_UP && keyCode != KeyEvent.KEYCODE_VOLUME_DOWN) return super.onKeyDown(keyCode, event)
|
||||
when (volumeButtons) {
|
||||
VolumeButtons.SNOOZE -> if (RingingService.ringing.value is Ring.AlarmRing) RingingService.snooze(this) else RingingService.stop(this)
|
||||
VolumeButtons.STOP -> RingingService.stop(this)
|
||||
VolumeButtons.NOTHING -> Unit
|
||||
VolumeButtons.VOLUME -> return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RingingScreen(ring: Ring, onSnooze: () -> Unit, onStop: () -> Unit) {
|
||||
val now = remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(Unit) { while (true) { now.longValue = System.currentTimeMillis(); delay(1000) } }
|
||||
val label = when (ring) {
|
||||
is Ring.AlarmRing -> ring.alarm.label.ifBlank { stringResource(R.string.alarm) }
|
||||
is Ring.TimerRing -> ring.label.ifBlank { stringResource(R.string.timer_done) }
|
||||
}
|
||||
Column(
|
||||
Modifier.fillMaxSize().background(WColor.ringBg).navigationBarsPadding().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
val big = when (ring) {
|
||||
is Ring.AlarmRing -> Fmt.clock(now.longValue)
|
||||
// a finished timer counts up the time since it ended
|
||||
is Ring.TimerRing -> "−" + Fmt.duration(now.longValue - ring.since)
|
||||
}
|
||||
Text(big, style = WType.ringing, color = WColor.ringOn)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(label, style = WType.title, color = WColor.ringOn)
|
||||
Spacer(Modifier.weight(1.2f))
|
||||
val snooze = if (ring is Ring.AlarmRing) onSnooze else null
|
||||
SlideToAct(
|
||||
leftLabel = if (snooze != null) stringResource(R.string.snooze) else stringResource(R.string.stop),
|
||||
rightLabel = stringResource(R.string.stop),
|
||||
icon = if (ring is Ring.AlarmRing) Icons.Rounded.Alarm else Icons.Rounded.HourglassBottom,
|
||||
onLeft = snooze ?: onStop, onRight = onStop,
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** Pill track with an alarm thumb in the middle: drag left to snooze, right to stop. */
|
||||
@Composable
|
||||
fun SlideToAct(leftLabel: String, rightLabel: String, icon: ImageVector, onLeft: () -> Unit, onRight: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val offset = remember { Animatable(0f) }
|
||||
BoxWithConstraints(
|
||||
Modifier.fillMaxWidth().height(76.dp).clip(RoundedCornerShape(38.dp)).background(WColor.ringTrack)
|
||||
.semantics {
|
||||
customActions = listOf(
|
||||
CustomAccessibilityAction(leftLabel) { onLeft(); true },
|
||||
CustomAccessibilityAction(rightLabel) { onRight(); true },
|
||||
)
|
||||
},
|
||||
) {
|
||||
val maxPx = with(LocalDensity.current) { (maxWidth / 2 - 38.dp).toPx() }
|
||||
Row(Modifier.fillMaxSize().padding(horizontal = 24.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(leftLabel, style = WType.label, color = WColor.ringOn, modifier = Modifier.clickable(onClick = onLeft))
|
||||
Text(rightLabel, style = WType.label, color = WColor.ringOn, modifier = Modifier.clickable(onClick = onRight))
|
||||
}
|
||||
Box(
|
||||
Modifier.align(Alignment.Center).offset { IntOffset(offset.value.roundToInt(), 0) }
|
||||
.size(width = 96.dp, height = 64.dp).clip(RoundedCornerShape(32.dp)).background(WColor.ringThumb)
|
||||
.pointerInput(maxPx) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragEnd = {
|
||||
scope.launch {
|
||||
val v = offset.value
|
||||
when {
|
||||
v < -maxPx * .6f -> { offset.snapTo(-maxPx); onLeft() }
|
||||
v > maxPx * .6f -> { offset.snapTo(maxPx); onRight() }
|
||||
else -> offset.animateTo(0f, spring(dampingRatio = .6f))
|
||||
}
|
||||
}
|
||||
},
|
||||
) { _, drag -> scope.launch { offset.snapTo((offset.value + drag).coerceIn(-maxPx, maxPx)) } }
|
||||
}
|
||||
.semantics { contentDescription = "$leftLabel / $rightLabel" },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Icon(icon, null, tint = WColor.onRingThumb, modifier = Modifier.size(28.dp)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package app.uhr.alarm
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioManager
|
||||
import android.media.MediaPlayer
|
||||
import android.media.RingtoneManager
|
||||
import android.net.Uri
|
||||
import android.os.IBinder
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import androidx.core.app.ServiceCompat
|
||||
import app.uhr.R
|
||||
import app.uhr.data.Alarm
|
||||
import app.uhr.data.Graph
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** What is ringing right now, for the full-screen activity. */
|
||||
sealed interface Ring {
|
||||
data class AlarmRing(val alarm: Alarm) : Ring
|
||||
data class TimerRing(val timerId: Long, val label: String, val since: Long) : Ring
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the alarm or timer: sound on the alarm stream with gradual volume, vibration,
|
||||
* a full-screen notification, and the "silence after" timeout.
|
||||
*/
|
||||
class RingingService : Service() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var player: MediaPlayer? = null
|
||||
private var vibrator: Vibrator? = null
|
||||
private var timeout: Job? = null
|
||||
private var ramp: Job? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Graph.init(this)
|
||||
val id = intent?.getLongExtra(AlarmReceiver.EXTRA_ID, -1) ?: -1
|
||||
// must be in the foreground within seconds, before any database work
|
||||
goForeground(getString(R.string.alarm), "")
|
||||
when (intent?.action) {
|
||||
ACTION_ALARM -> scope.launch { startAlarm(id) }
|
||||
ACTION_TIMER -> scope.launch { startTimer(id) }
|
||||
ACTION_SNOOZE -> scope.launch { (current.value as? Ring.AlarmRing)?.let { Graph.repo.snooze(it.alarm.id) }; stop() }
|
||||
ACTION_STOP -> scope.launch { stopCurrent(); stop() }
|
||||
else -> stop()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private suspend fun startAlarm(id: Long) {
|
||||
val repo = Graph.repo
|
||||
val alarm = repo.alarm(id) ?: return stop()
|
||||
if (!alarm.enabled) return stop()
|
||||
repo.markRinging(id)
|
||||
val ring = Ring.AlarmRing(alarm)
|
||||
current.value = ring
|
||||
val title = alarm.label.ifBlank { getString(R.string.alarm) }
|
||||
goForeground(title, String.format("%d:%02d", alarm.hour, alarm.minute), snooze = true)
|
||||
val s = repo.prefs.settingsOnce()
|
||||
play(alarm.ringtoneUri, s.alarmVolume, s.gradualVolumeS)
|
||||
if (alarm.vibrate) vibrate()
|
||||
timeout?.cancel()
|
||||
timeout = scope.launch {
|
||||
delay(s.silenceAfterMin * 60_000L)
|
||||
repo.finishAlarm(id, missed = true)
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun startTimer(id: Long) {
|
||||
val repo = Graph.repo
|
||||
val t = repo.timer(id) ?: return stop()
|
||||
repo.markTimerRinging(id)
|
||||
current.value = Ring.TimerRing(id, t.label, t.endAt ?: System.currentTimeMillis())
|
||||
goForeground(t.label.ifBlank { getString(R.string.timer_done) }, getString(R.string.timer_done), snooze = false)
|
||||
val s = repo.prefs.settingsOnce()
|
||||
play(s.timerSound, s.alarmVolume, 0)
|
||||
if (s.timerVibrate) vibrate()
|
||||
timeout?.cancel()
|
||||
timeout = scope.launch { delay(s.silenceAfterMin * 60_000L); stopCurrent(); stop() }
|
||||
}
|
||||
|
||||
private suspend fun stopCurrent() {
|
||||
when (val r = current.value) {
|
||||
is Ring.AlarmRing -> Graph.repo.finishAlarm(r.alarm.id)
|
||||
is Ring.TimerRing -> Graph.repo.deleteTimer(r.timerId)
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun goForeground(title: String, text: String, snooze: Boolean = false) {
|
||||
val full = PendingIntent.getActivity(
|
||||
this, 0, Intent(this, RingingActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
val n = Notifications.ringing(this, title, text, full, self(ACTION_STOP), if (snooze) self(ACTION_SNOOZE) else null)
|
||||
ServiceCompat.startForeground(this, Notifications.ID_RINGING, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
|
||||
}
|
||||
|
||||
private fun self(action: String) = PendingIntent.getService(
|
||||
this, action.hashCode(), Intent(this, RingingService::class.java).setAction(action), PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private fun play(uri: String?, volumePercent: Int, rampSeconds: Int) {
|
||||
player?.release(); player = null
|
||||
if (uri == Alarm.SILENT) return
|
||||
val audio = getSystemService(AudioManager::class.java)
|
||||
val target = (volumePercent / 100f).coerceIn(0.05f, 1f)
|
||||
val candidates = listOfNotNull(
|
||||
uri?.let(Uri::parse),
|
||||
RingtoneManager.getActualDefaultRingtoneUri(this, RingtoneManager.TYPE_ALARM),
|
||||
Uri.parse("android.resource://$packageName/${R.raw.alarm_default}"),
|
||||
)
|
||||
// a picked file may be gone: try the next one rather than ring silently
|
||||
for (u in candidates) {
|
||||
val p = runCatching {
|
||||
MediaPlayer().apply {
|
||||
setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_ALARM).setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION).build())
|
||||
setDataSource(this@RingingService, u)
|
||||
isLooping = true
|
||||
prepare()
|
||||
}
|
||||
}.getOrNull() ?: continue
|
||||
player = p
|
||||
break
|
||||
}
|
||||
val p = player ?: return
|
||||
if (audio.getStreamVolume(AudioManager.STREAM_ALARM) == 0) {
|
||||
audio.setStreamVolume(AudioManager.STREAM_ALARM, audio.getStreamMaxVolume(AudioManager.STREAM_ALARM) / 2, 0)
|
||||
}
|
||||
p.setVolume(if (rampSeconds > 0) 0.05f else target, if (rampSeconds > 0) 0.05f else target)
|
||||
p.start()
|
||||
ramp?.cancel()
|
||||
if (rampSeconds > 0) ramp = scope.launch {
|
||||
val steps = rampSeconds * 4
|
||||
for (i in 1..steps) {
|
||||
delay(250)
|
||||
val v = 0.05f + (target - 0.05f) * i / steps
|
||||
runCatching { p.setVolume(v, v) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun vibrate() {
|
||||
val v = getSystemService(VibratorManager::class.java)?.defaultVibrator ?: return
|
||||
vibrator = v
|
||||
v.vibrate(VibrationEffect.createWaveform(longArrayOf(0, 600, 400, 600, 1200), 0),
|
||||
android.os.VibrationAttributes.createForUsage(android.os.VibrationAttributes.USAGE_ALARM))
|
||||
}
|
||||
|
||||
private fun stop() {
|
||||
timeout?.cancel(); ramp?.cancel()
|
||||
player?.runCatching { stop(); release() }; player = null
|
||||
vibrator?.cancel(); vibrator = null
|
||||
current.value = null
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
player?.release(); vibrator?.cancel(); current.value = null
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ACTION_ALARM = "alarm"
|
||||
private const val ACTION_TIMER = "timer"
|
||||
const val ACTION_SNOOZE = "snooze"
|
||||
const val ACTION_STOP = "stop"
|
||||
|
||||
private val _current = MutableStateFlow<Ring?>(null)
|
||||
val current: MutableStateFlow<Ring?> get() = _current
|
||||
val ringing: StateFlow<Ring?> get() = _current
|
||||
|
||||
fun startAlarm(context: Context, id: Long) = start(context, ACTION_ALARM, id)
|
||||
fun startTimer(context: Context, id: Long) = start(context, ACTION_TIMER, id)
|
||||
fun snooze(context: Context) = start(context, ACTION_SNOOZE, -1)
|
||||
fun stop(context: Context) = start(context, ACTION_STOP, -1)
|
||||
|
||||
private fun start(context: Context, action: String, id: Long) = context.startForegroundService(
|
||||
Intent(context, RingingService::class.java).setAction(action).putExtra(AlarmReceiver.EXTRA_ID, id),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package app.uhr.alarm
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import app.uhr.MainActivity
|
||||
import app.uhr.data.Alarm
|
||||
import app.uhr.data.AlarmInstance
|
||||
import app.uhr.data.ClockDao
|
||||
import app.uhr.data.InstanceState
|
||||
import app.uhr.data.Timer
|
||||
import app.uhr.data.nextRing
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/** All AlarmManager access. Alarms use setAlarmClock: Doze-exempt and shown in the status bar. */
|
||||
class Scheduler(private val context: Context, private val dao: ClockDao) {
|
||||
private val am = context.getSystemService(AlarmManager::class.java)
|
||||
|
||||
suspend fun reschedule(alarm: Alarm, now: ZonedDateTime = ZonedDateTime.now()) {
|
||||
cancel(alarm.id)
|
||||
if (!alarm.enabled) { dao.deleteInstance(alarm.id); Notifications.cancelAlarm(context, alarm.id); return }
|
||||
val fireAt = nextRing(alarm, now).toInstant().toEpochMilli()
|
||||
dao.putInstance(AlarmInstance(alarm.id, fireAt, InstanceState.SCHEDULED))
|
||||
setRing(alarm.id, fireAt)
|
||||
scheduleUpcomingNotice(alarm.id, fireAt)
|
||||
Notifications.cancelAlarm(context, alarm.id)
|
||||
}
|
||||
|
||||
suspend fun snooze(alarmId: Long, minutes: Int) {
|
||||
val fireAt = System.currentTimeMillis() + minutes * 60_000L
|
||||
dao.putInstance(AlarmInstance(alarmId, fireAt, InstanceState.SNOOZED))
|
||||
setRing(alarmId, fireAt)
|
||||
}
|
||||
|
||||
/** After a reboot, a time change or an app update: every alarm from scratch, snoozes kept. */
|
||||
suspend fun rescheduleAll() {
|
||||
val instances = dao.instancesOnce().associateBy { it.alarmId }
|
||||
val now = System.currentTimeMillis()
|
||||
for (alarm in dao.alarmsOnce()) {
|
||||
val snoozed = instances[alarm.id]?.takeIf { it.state == InstanceState.SNOOZED && it.fireAt > now }
|
||||
if (alarm.enabled && snoozed != null) setRing(alarm.id, snoozed.fireAt) else reschedule(alarm)
|
||||
}
|
||||
dao.timersOnce().filter { it.endAt != null && it.state == app.uhr.data.TimerState.RUNNING }.forEach { scheduleTimer(it) }
|
||||
}
|
||||
|
||||
fun cancel(alarmId: Long) {
|
||||
am.cancel(ringIntent(alarmId))
|
||||
am.cancel(upcomingIntent(alarmId))
|
||||
}
|
||||
|
||||
private fun setRing(alarmId: Long, fireAt: Long) {
|
||||
val show = PendingIntent.getActivity(
|
||||
context, alarmId.toInt(), Intent(context, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
am.setAlarmClock(AlarmManager.AlarmClockInfo(fireAt, show), ringIntent(alarmId))
|
||||
}
|
||||
|
||||
private fun scheduleUpcomingNotice(alarmId: Long, fireAt: Long) {
|
||||
val at = fireAt - UPCOMING_MS
|
||||
if (at > System.currentTimeMillis()) am.set(AlarmManager.RTC_WAKEUP, at, upcomingIntent(alarmId))
|
||||
}
|
||||
|
||||
fun scheduleTimer(timer: Timer) {
|
||||
val end = timer.endAt ?: return
|
||||
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, end, timerIntent(timer.id))
|
||||
}
|
||||
|
||||
fun cancelTimer(id: Long) = am.cancel(timerIntent(id))
|
||||
|
||||
fun scheduleBedtimeReminder(at: Long) =
|
||||
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, broadcast(AlarmReceiver.ACTION_BEDTIME, 0, REQ_BEDTIME))
|
||||
|
||||
fun cancelBedtimeReminder() = am.cancel(broadcast(AlarmReceiver.ACTION_BEDTIME, 0, REQ_BEDTIME))
|
||||
|
||||
private fun ringIntent(id: Long) = broadcast(AlarmReceiver.ACTION_RING, id, REQ_RING)
|
||||
private fun upcomingIntent(id: Long) = broadcast(AlarmReceiver.ACTION_UPCOMING, id, REQ_UPCOMING)
|
||||
private fun timerIntent(id: Long) = broadcast(AlarmReceiver.ACTION_TIMER, id, REQ_TIMER)
|
||||
|
||||
private fun broadcast(action: String, id: Long, base: Int): PendingIntent = PendingIntent.getBroadcast(
|
||||
context, base + id.toInt(),
|
||||
Intent(context, AlarmReceiver::class.java).setAction(action).putExtra(AlarmReceiver.EXTRA_ID, id),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val UPCOMING_MS = 2 * 60 * 60 * 1000L
|
||||
private const val REQ_RING = 0
|
||||
private const val REQ_UPCOMING = 1_000_000
|
||||
private const val REQ_TIMER = 2_000_000
|
||||
private const val REQ_BEDTIME = 3_000_000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package app.uhr.data
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import app.uhr.alarm.Notifications
|
||||
import app.uhr.alarm.Scheduler
|
||||
import app.uhr.widget.updateClockWidgets
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.time.LocalDate
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
class ClockRepo(private val context: Context, private val dao: ClockDao, val prefs: Prefs) {
|
||||
val scheduler = Scheduler(context, dao)
|
||||
/** Ringing, snooze and dismiss can race (notification vs. screen); every transition goes through here. */
|
||||
private val lock = Mutex()
|
||||
|
||||
// --- alarms -------------------------------------------------------------------------------
|
||||
|
||||
val alarms: Flow<List<AlarmWithInstance>> = dao.alarms()
|
||||
val nextInstance: Flow<AlarmInstance?> = dao.nextInstance()
|
||||
suspend fun alarm(id: Long) = dao.alarm(id)
|
||||
suspend fun instance(id: Long) = dao.instance(id)
|
||||
suspend fun nextInstanceOnce() = dao.nextInstanceOnce()
|
||||
|
||||
suspend fun save(alarm: Alarm): Long = lock.withLock {
|
||||
val stamped = alarm.copy(updatedAt = System.currentTimeMillis())
|
||||
val id = if (alarm.id == 0L) dao.insert(stamped) else stamped.id.also { dao.update(stamped) }
|
||||
dao.alarm(id)?.let { scheduler.reschedule(it) }
|
||||
updateClockWidgets(context)
|
||||
id
|
||||
}
|
||||
|
||||
suspend fun delete(alarm: Alarm) = lock.withLock {
|
||||
scheduler.cancel(alarm.id)
|
||||
Notifications.cancelAlarm(context, alarm.id)
|
||||
dao.delete(alarm)
|
||||
updateClockWidgets(context)
|
||||
}
|
||||
|
||||
/** Re-insert after "undo"; keeps the id so nothing else has to change. */
|
||||
suspend fun restore(alarm: Alarm) = lock.withLock {
|
||||
dao.insert(alarm)
|
||||
scheduler.reschedule(alarm)
|
||||
updateClockWidgets(context)
|
||||
}
|
||||
|
||||
suspend fun snooze(id: Long) = lock.withLock {
|
||||
val minutes = prefs.settingsOnce().snoozeMin
|
||||
scheduler.snooze(id, minutes)
|
||||
dao.alarm(id)?.let { a -> dao.instance(id)?.let { Notifications.snoozed(context, a, it.fireAt) } }
|
||||
updateClockWidgets(context)
|
||||
}
|
||||
|
||||
/** Stop: a one-time alarm switches off, a repeating one moves to its next day. */
|
||||
suspend fun finishAlarm(id: Long, missed: Boolean = false) = lock.withLock {
|
||||
val alarm = dao.alarm(id) ?: return@withLock
|
||||
Notifications.cancelAlarm(context, id)
|
||||
if (missed) Notifications.missed(context, alarm)
|
||||
if (alarm.repeats) scheduler.reschedule(alarm)
|
||||
else {
|
||||
val off = alarm.copy(enabled = false, skipUntil = null)
|
||||
dao.update(off); scheduler.reschedule(off)
|
||||
}
|
||||
updateClockWidgets(context)
|
||||
}
|
||||
|
||||
/** Skip only the upcoming ring ("dismiss now" before it rings). */
|
||||
suspend fun dismissNext(id: Long) = lock.withLock {
|
||||
val alarm = dao.alarm(id) ?: return@withLock
|
||||
val inst = dao.instance(id) ?: return@withLock
|
||||
Notifications.cancelAlarm(context, id)
|
||||
if (!alarm.repeats) { val off = alarm.copy(enabled = false); dao.update(off); scheduler.reschedule(off) }
|
||||
else {
|
||||
val after = java.time.Instant.ofEpochMilli(inst.fireAt).atZone(java.time.ZoneId.systemDefault()).plusMinutes(1)
|
||||
scheduler.reschedule(alarm, after)
|
||||
}
|
||||
updateClockWidgets(context)
|
||||
}
|
||||
|
||||
suspend fun markRinging(id: Long) = lock.withLock {
|
||||
dao.instance(id)?.let { dao.putInstance(it.copy(state = InstanceState.RINGING, updatedAt = System.currentTimeMillis())) }
|
||||
}
|
||||
|
||||
// --- timers -------------------------------------------------------------------------------
|
||||
|
||||
val timers: Flow<List<Timer>> = dao.timers()
|
||||
suspend fun timer(id: Long) = dao.timer(id)
|
||||
|
||||
suspend fun startTimer(durationMs: Long, label: String = ""): Long {
|
||||
val end = System.currentTimeMillis() + durationMs
|
||||
val id = dao.insert(Timer(label = label, durationMs = durationMs, remainingMs = durationMs, endAt = end, state = TimerState.RUNNING))
|
||||
dao.timer(id)?.let { scheduler.scheduleTimer(it); Notifications.timers(context, dao.timersOnce()) }
|
||||
return id
|
||||
}
|
||||
|
||||
suspend fun pauseTimer(id: Long) = updateTimer(id) { t ->
|
||||
if (t.state != TimerState.RUNNING) t else t.copy(state = TimerState.PAUSED, remainingMs = t.remaining().coerceAtLeast(0), endAt = null)
|
||||
}
|
||||
|
||||
suspend fun resumeTimer(id: Long) = updateTimer(id) { t ->
|
||||
if (t.state != TimerState.PAUSED) t else t.copy(state = TimerState.RUNNING, endAt = System.currentTimeMillis() + t.remainingMs)
|
||||
}
|
||||
|
||||
suspend fun addMinute(id: Long) = updateTimer(id) { t ->
|
||||
when (t.state) {
|
||||
TimerState.PAUSED -> t.copy(remainingMs = t.remainingMs + 60_000, durationMs = t.durationMs + 60_000)
|
||||
// a ringing timer restarts with one minute
|
||||
TimerState.RINGING -> t.copy(state = TimerState.RUNNING, endAt = System.currentTimeMillis() + 60_000, durationMs = 60_000, remainingMs = 60_000)
|
||||
TimerState.RUNNING -> t.copy(endAt = (t.endAt ?: System.currentTimeMillis()) + 60_000, durationMs = t.durationMs + 60_000)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resetTimer(id: Long) = updateTimer(id) { t ->
|
||||
t.copy(state = TimerState.PAUSED, remainingMs = t.durationMs, endAt = null)
|
||||
}
|
||||
|
||||
suspend fun setTimerLabel(id: Long, label: String) = updateTimer(id) { it.copy(label = label) }
|
||||
|
||||
suspend fun markTimerRinging(id: Long) = updateTimer(id) { it.copy(state = TimerState.RINGING) }
|
||||
|
||||
suspend fun deleteTimer(id: Long) {
|
||||
scheduler.cancelTimer(id); dao.deleteTimer(id)
|
||||
Notifications.timers(context, dao.timersOnce())
|
||||
}
|
||||
|
||||
private suspend fun updateTimer(id: Long, f: (Timer) -> Timer) {
|
||||
val t = dao.timer(id) ?: return
|
||||
val n = f(t)
|
||||
dao.update(n)
|
||||
scheduler.cancelTimer(id)
|
||||
if (n.state == TimerState.RUNNING) scheduler.scheduleTimer(n)
|
||||
Notifications.timers(context, dao.timersOnce())
|
||||
}
|
||||
|
||||
// --- stopwatch ----------------------------------------------------------------------------
|
||||
|
||||
val stopwatch: Flow<Stopwatch> = prefs.stopwatch
|
||||
|
||||
suspend fun stopwatchToggle() {
|
||||
val s = prefs.stopwatch.first()
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val next = when (s.state) {
|
||||
StopwatchState.RUNNING -> s.copy(state = StopwatchState.STOPPED, accumulatedMs = s.elapsed(now))
|
||||
else -> s.copy(state = StopwatchState.RUNNING, startedAt = now)
|
||||
}
|
||||
prefs.setStopwatch(next)
|
||||
Notifications.stopwatch(context, next)
|
||||
}
|
||||
|
||||
suspend fun stopwatchLap() {
|
||||
val s = prefs.stopwatch.first()
|
||||
if (s.state != StopwatchState.RUNNING) return
|
||||
prefs.setStopwatch(s.copy(laps = s.laps + s.elapsed(SystemClock.elapsedRealtime())))
|
||||
}
|
||||
|
||||
suspend fun stopwatchReset() {
|
||||
prefs.setStopwatch(Stopwatch())
|
||||
Notifications.stopwatch(context, Stopwatch())
|
||||
}
|
||||
|
||||
// --- world clock --------------------------------------------------------------------------
|
||||
|
||||
val cities: Flow<List<City>> = dao.cities()
|
||||
suspend fun addCity(hit: ZoneHit) { dao.insert(City(zoneId = hit.zoneId, name = hit.city, sortOrder = dao.nextCityOrder())); updateClockWidgets(context) }
|
||||
suspend fun deleteCity(id: Long) { dao.deleteCity(id); updateClockWidgets(context) }
|
||||
suspend fun reorderCities(ids: List<Long>) = dao.reorderCities(ids)
|
||||
|
||||
// --- bedtime ------------------------------------------------------------------------------
|
||||
|
||||
val bedtime: Flow<Bedtime> = prefs.bedtime
|
||||
|
||||
/** Bedtime owns exactly one alarm (is_bedtime_wake) and one reminder. */
|
||||
suspend fun saveBedtime(b: Bedtime) {
|
||||
prefs.setBedtime(b)
|
||||
val existing = dao.bedtimeAlarm()
|
||||
val wake = (existing ?: Alarm(hour = b.wakeHour, minute = b.wakeMinute, isBedtimeWake = true))
|
||||
.copy(hour = b.wakeHour, minute = b.wakeMinute, days = b.days, enabled = b.enabled, isBedtimeWake = true)
|
||||
save(wake)
|
||||
scheduleBedtime()
|
||||
}
|
||||
|
||||
suspend fun scheduleBedtime() {
|
||||
val b = prefs.bedtime.first()
|
||||
scheduler.cancelBedtimeReminder()
|
||||
if (!b.enabled || b.reminderMin < 0) return
|
||||
val reminder = Alarm(hour = b.bedHour, minute = b.bedMinute, days = b.days)
|
||||
val at = nextRing(reminder, ZonedDateTime.now().plusMinutes(b.reminderMin.toLong())).minusMinutes(b.reminderMin.toLong())
|
||||
scheduler.scheduleBedtimeReminder(at.toInstant().toEpochMilli())
|
||||
}
|
||||
|
||||
fun today(): LocalDate = LocalDate.now()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package app.uhr.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
||||
enum class VolumeButtons { VOLUME, SNOOZE, STOP, NOTHING }
|
||||
enum class ClockStyle { DIGITAL, ANALOG }
|
||||
|
||||
data class Settings(
|
||||
val silenceAfterMin: Int = 10,
|
||||
val snoozeMin: Int = 10,
|
||||
/** 0..100, share of the alarm stream's max volume */
|
||||
val alarmVolume: Int = 80,
|
||||
/** seconds to ramp up from quiet; 0 = off */
|
||||
val gradualVolumeS: Int = 30,
|
||||
val volumeButtons: VolumeButtons = VolumeButtons.VOLUME,
|
||||
/** java.time.DayOfWeek value, 1 = Monday */
|
||||
val weekStart: Int = 1,
|
||||
val clockStyle: ClockStyle = ClockStyle.DIGITAL,
|
||||
val showSeconds: Boolean = false,
|
||||
val autoHomeClock: Boolean = true,
|
||||
val homeZone: String? = null,
|
||||
val timerSound: String? = null,
|
||||
val timerVibrate: Boolean = true,
|
||||
val theme: ThemeMode = ThemeMode.SYSTEM,
|
||||
)
|
||||
|
||||
enum class StopwatchState { IDLE, RUNNING, STOPPED }
|
||||
|
||||
/** Timestamps only (elapsedRealtime), never a running counter, so process death changes nothing. */
|
||||
data class Stopwatch(
|
||||
val state: StopwatchState = StopwatchState.IDLE,
|
||||
val startedAt: Long = 0,
|
||||
val accumulatedMs: Long = 0,
|
||||
val laps: List<Long> = emptyList(),
|
||||
) {
|
||||
fun elapsed(now: Long): Long = accumulatedMs + if (state == StopwatchState.RUNNING) now - startedAt else 0
|
||||
}
|
||||
|
||||
data class Bedtime(
|
||||
val enabled: Boolean = false,
|
||||
val bedHour: Int = 23,
|
||||
val bedMinute: Int = 0,
|
||||
val wakeHour: Int = 7,
|
||||
val wakeMinute: Int = 0,
|
||||
val days: Int = Alarm.WEEKDAYS,
|
||||
val reminderMin: Int = 15,
|
||||
)
|
||||
|
||||
private val Context.store by preferencesDataStore("clock")
|
||||
|
||||
class Prefs(private val context: Context) {
|
||||
private object K {
|
||||
val silence = intPreferencesKey("silence_after")
|
||||
val snooze = intPreferencesKey("snooze")
|
||||
val volume = intPreferencesKey("alarm_volume")
|
||||
val gradual = intPreferencesKey("gradual")
|
||||
val buttons = stringPreferencesKey("volume_buttons")
|
||||
val weekStart = intPreferencesKey("week_start")
|
||||
val style = stringPreferencesKey("clock_style")
|
||||
val seconds = booleanPreferencesKey("seconds")
|
||||
val autoHome = booleanPreferencesKey("auto_home")
|
||||
val homeZone = stringPreferencesKey("home_zone")
|
||||
val timerSound = stringPreferencesKey("timer_sound")
|
||||
val timerVibrate = booleanPreferencesKey("timer_vibrate")
|
||||
val theme = stringPreferencesKey("theme")
|
||||
val swState = stringPreferencesKey("sw_state")
|
||||
val swStarted = longPreferencesKey("sw_started")
|
||||
val swAcc = longPreferencesKey("sw_acc")
|
||||
val swLaps = stringPreferencesKey("sw_laps")
|
||||
val bedOn = booleanPreferencesKey("bed_on")
|
||||
val bedH = intPreferencesKey("bed_h")
|
||||
val bedM = intPreferencesKey("bed_m")
|
||||
val wakeH = intPreferencesKey("wake_h")
|
||||
val wakeM = intPreferencesKey("wake_m")
|
||||
val bedDays = intPreferencesKey("bed_days")
|
||||
val bedReminder = intPreferencesKey("bed_reminder")
|
||||
}
|
||||
|
||||
private fun Preferences.settings(): Settings {
|
||||
val d = Settings()
|
||||
return Settings(
|
||||
silenceAfterMin = this[K.silence] ?: d.silenceAfterMin,
|
||||
snoozeMin = this[K.snooze] ?: d.snoozeMin,
|
||||
alarmVolume = this[K.volume] ?: d.alarmVolume,
|
||||
gradualVolumeS = this[K.gradual] ?: d.gradualVolumeS,
|
||||
volumeButtons = this[K.buttons].enumOr(d.volumeButtons),
|
||||
weekStart = this[K.weekStart] ?: d.weekStart,
|
||||
clockStyle = this[K.style].enumOr(d.clockStyle),
|
||||
showSeconds = this[K.seconds] ?: d.showSeconds,
|
||||
autoHomeClock = this[K.autoHome] ?: d.autoHomeClock,
|
||||
homeZone = this[K.homeZone],
|
||||
timerSound = this[K.timerSound],
|
||||
timerVibrate = this[K.timerVibrate] ?: d.timerVibrate,
|
||||
theme = this[K.theme].enumOr(d.theme),
|
||||
)
|
||||
}
|
||||
|
||||
val settings: Flow<Settings> = context.store.data.map { it.settings() }
|
||||
suspend fun settingsOnce(): Settings = settings.first()
|
||||
|
||||
suspend fun update(transform: (Settings) -> Settings) {
|
||||
context.store.edit { p ->
|
||||
val s = transform(p.settings())
|
||||
p[K.silence] = s.silenceAfterMin; p[K.snooze] = s.snoozeMin; p[K.volume] = s.alarmVolume
|
||||
p[K.gradual] = s.gradualVolumeS; p[K.buttons] = s.volumeButtons.name; p[K.weekStart] = s.weekStart
|
||||
p[K.style] = s.clockStyle.name; p[K.seconds] = s.showSeconds; p[K.autoHome] = s.autoHomeClock
|
||||
if (s.homeZone != null) p[K.homeZone] = s.homeZone else p.remove(K.homeZone)
|
||||
if (s.timerSound != null) p[K.timerSound] = s.timerSound else p.remove(K.timerSound)
|
||||
p[K.timerVibrate] = s.timerVibrate; p[K.theme] = s.theme.name
|
||||
}
|
||||
}
|
||||
|
||||
val stopwatch: Flow<Stopwatch> = context.store.data.map { p ->
|
||||
Stopwatch(
|
||||
state = p[K.swState].enumOr(StopwatchState.IDLE),
|
||||
startedAt = p[K.swStarted] ?: 0,
|
||||
accumulatedMs = p[K.swAcc] ?: 0,
|
||||
laps = p[K.swLaps]?.split(',')?.filter { it.isNotBlank() }?.map { it.toLong() } ?: emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setStopwatch(s: Stopwatch) {
|
||||
context.store.edit { p ->
|
||||
p[K.swState] = s.state.name; p[K.swStarted] = s.startedAt; p[K.swAcc] = s.accumulatedMs
|
||||
p[K.swLaps] = s.laps.joinToString(",")
|
||||
}
|
||||
}
|
||||
|
||||
val bedtime: Flow<Bedtime> = context.store.data.map { p ->
|
||||
val d = Bedtime()
|
||||
Bedtime(
|
||||
enabled = p[K.bedOn] ?: d.enabled, bedHour = p[K.bedH] ?: d.bedHour, bedMinute = p[K.bedM] ?: d.bedMinute,
|
||||
wakeHour = p[K.wakeH] ?: d.wakeHour, wakeMinute = p[K.wakeM] ?: d.wakeMinute,
|
||||
days = p[K.bedDays] ?: d.days, reminderMin = p[K.bedReminder] ?: d.reminderMin,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setBedtime(b: Bedtime) {
|
||||
context.store.edit { p ->
|
||||
p[K.bedOn] = b.enabled; p[K.bedH] = b.bedHour; p[K.bedM] = b.bedMinute; p[K.wakeH] = b.wakeHour
|
||||
p[K.wakeM] = b.wakeMinute; p[K.bedDays] = b.days; p[K.bedReminder] = b.reminderMin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified E : Enum<E>> String?.enumOr(default: E): E =
|
||||
this?.let { runCatching { enumValueOf<E>(it) }.getOrNull() } ?: default
|
||||
@@ -0,0 +1,34 @@
|
||||
package app.uhr.data
|
||||
|
||||
import java.time.ZoneId
|
||||
import java.util.Locale
|
||||
|
||||
data class ZoneHit(val zoneId: String, val city: String, val region: String)
|
||||
|
||||
/** Offline city search over the system time zone database; names localized by ICU. */
|
||||
object Zones {
|
||||
private val all: List<ZoneHit> by lazy {
|
||||
ZoneId.getAvailableZoneIds()
|
||||
.filter { it.contains('/') && !it.startsWith("Etc/") && !it.startsWith("SystemV/") && it.first().isUpperCase() }
|
||||
.mapNotNull { id ->
|
||||
val parts = id.split('/')
|
||||
val city = cityName(id) ?: parts.last().replace('_', ' ')
|
||||
ZoneHit(id, city, parts.first())
|
||||
}
|
||||
.distinctBy { it.city to it.region }
|
||||
.sortedBy { it.city }
|
||||
}
|
||||
|
||||
fun cityName(zoneId: String, locale: Locale = Locale.getDefault()): String? =
|
||||
runCatching {
|
||||
android.icu.text.TimeZoneNames.getInstance(android.icu.util.ULocale.forLocale(locale)).getExemplarLocationName(zoneId)
|
||||
}.getOrNull()?.takeIf { it.isNotBlank() }
|
||||
|
||||
fun search(query: String): List<ZoneHit> {
|
||||
val q = query.trim()
|
||||
if (q.isEmpty()) return all.take(50)
|
||||
return all.filter { it.city.contains(q, ignoreCase = true) || it.zoneId.contains(q.replace(' ', '_'), ignoreCase = true) }
|
||||
.sortedBy { if (it.city.startsWith(q, ignoreCase = true)) 0 else 1 }
|
||||
.take(50)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package app.uhr.ui.components
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import java.time.ZonedDateTime
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
/** Our own face: filled disc, thick rounded hour hand, long minute hand, optional second hand. */
|
||||
@Composable
|
||||
fun AnalogClock(time: ZonedDateTime, size: Dp, face: Color, hourHand: Color, minuteHand: Color, secondHand: Color?, modifier: Modifier = Modifier) {
|
||||
Canvas(modifier.size(size)) { drawAnalog(time.hour, time.minute, time.second, face, hourHand, minuteHand, secondHand) }
|
||||
}
|
||||
|
||||
fun DrawScope.drawAnalog(h: Int, m: Int, s: Int, face: Color, hourHand: Color, minuteHand: Color, secondHand: Color?) {
|
||||
val r = size.minDimension / 2
|
||||
val c = center
|
||||
drawCircle(face, r, c)
|
||||
for (i in 0 until 12) {
|
||||
val a = 2 * PI * i / 12
|
||||
drawCircle(minuteHand.copy(alpha = .35f), r * .025f, Offset(c.x + r * .86f * sin(a).toFloat(), c.y - r * .86f * cos(a).toFloat()))
|
||||
}
|
||||
fun hand(angle: Double, length: Float, width: Float, color: Color) {
|
||||
drawLine(color, c, Offset(c.x + length * sin(angle).toFloat(), c.y - length * cos(angle).toFloat()), width, StrokeCap.Round)
|
||||
}
|
||||
hand(2 * PI * ((h % 12) + m / 60.0) / 12, r * .5f, r * .11f, hourHand)
|
||||
hand(2 * PI * (m + s / 60.0) / 60, r * .74f, r * .06f, minuteHand)
|
||||
if (secondHand != null) hand(2 * PI * s / 60, r * .8f, r * .02f, secondHand)
|
||||
drawCircle(minuteHand, r * .06f, c)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package app.uhr.ui.components
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.text.format.DateFormat
|
||||
import app.uhr.R
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.format.TextStyle
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
|
||||
object Fmt {
|
||||
/** Set once from the system setting (DateFormat.is24HourFormat). */
|
||||
var is24h: Boolean = true
|
||||
|
||||
fun time(h: Int, m: Int): String =
|
||||
if (is24h) String.format(Locale.getDefault(), "%d:%02d", h, m)
|
||||
else String.format(Locale.getDefault(), "%d:%02d", if (h % 12 == 0) 12 else h % 12, m)
|
||||
|
||||
fun ampm(h: Int): String? = if (is24h) null else if (h < 12) "AM" else "PM"
|
||||
|
||||
fun clock(at: Long, zone: ZoneId = ZoneId.systemDefault(), seconds: Boolean = false): String {
|
||||
val t = Instant.ofEpochMilli(at).atZone(zone)
|
||||
val base = time(t.hour, t.minute)
|
||||
return if (seconds) base + String.format(Locale.getDefault(), ":%02d", t.second) else base
|
||||
}
|
||||
|
||||
fun longDate(at: Long): String =
|
||||
Instant.ofEpochMilli(at).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("EEE, d. MMMM", Locale.getDefault()))
|
||||
|
||||
fun dayShort(d: DayOfWeek): String = d.getDisplayName(TextStyle.SHORT, Locale.getDefault()).trimEnd('.')
|
||||
fun dayNarrow(d: DayOfWeek): String = d.getDisplayName(TextStyle.NARROW, Locale.getDefault())
|
||||
fun dayFull(d: DayOfWeek): String = d.getDisplayName(TextStyle.FULL, Locale.getDefault())
|
||||
|
||||
/** mm:ss or h:mm:ss */
|
||||
fun duration(ms: Long): String {
|
||||
val s = abs(ms) / 1000
|
||||
val h = s / 3600; val m = s / 60 % 60; val sec = s % 60
|
||||
return if (h > 0) String.format(Locale.getDefault(), "%d:%02d:%02d", h, m, sec) else String.format(Locale.getDefault(), "%d:%02d", m, sec)
|
||||
}
|
||||
|
||||
/** mm:ss.cc, with hours when needed */
|
||||
fun stopwatch(ms: Long): String {
|
||||
val cs = ms / 10 % 100; val s = ms / 1000
|
||||
val h = s / 3600; val m = s / 60 % 60; val sec = s % 60
|
||||
return if (h > 0) String.format(Locale.getDefault(), "%d:%02d:%02d.%02d", h, m, sec, cs)
|
||||
else String.format(Locale.getDefault(), "%02d:%02d.%02d", m, sec, cs)
|
||||
}
|
||||
|
||||
/** "in 7 Std. 12 Min." */
|
||||
fun until(res: Resources, fireAt: Long, now: Long = System.currentTimeMillis()): String {
|
||||
val mins = ((fireAt - now + 59_999) / 60_000).coerceAtLeast(0)
|
||||
val d = mins / 1440; val h = mins / 60 % 24; val m = mins % 60
|
||||
return when {
|
||||
d > 0 -> res.getString(R.string.in_days_hours, d, h)
|
||||
h > 0 -> res.getString(R.string.in_hours_minutes, h, m)
|
||||
else -> res.getString(R.string.in_minutes, m)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Heute", "Morgen", or the weekday, for the next ring. */
|
||||
fun ringDay(res: Resources, fireAt: Long): String {
|
||||
val d = Instant.ofEpochMilli(fireAt).atZone(ZoneId.systemDefault()).toLocalDate()
|
||||
val today = LocalDate.now()
|
||||
return when (d) {
|
||||
today -> res.getString(R.string.today)
|
||||
today.plusDays(1) -> res.getString(R.string.tomorrow)
|
||||
else -> d.format(DateTimeFormatter.ofPattern("EEE, d. MMM", Locale.getDefault()))
|
||||
}
|
||||
}
|
||||
|
||||
/** Repeat summary: "Jeden Tag", "Werktags", "Mo, Mi, Fr". */
|
||||
fun days(res: Resources, days: Int, weekStart: Int): String = when (days) {
|
||||
0 -> ""
|
||||
0b1111111 -> res.getString(R.string.every_day)
|
||||
0b0011111 -> res.getString(R.string.weekdays)
|
||||
0b1100000 -> res.getString(R.string.weekends)
|
||||
else -> orderedDays(weekStart).filter { days and (1 shl (it.value - 1)) != 0 }.joinToString(", ") { dayShort(it) }
|
||||
}
|
||||
|
||||
fun orderedDays(weekStart: Int): List<DayOfWeek> = (0 until 7).map { DayOfWeek.of((weekStart - 1 + it) % 7 + 1) }
|
||||
|
||||
/** "+7 Std." / "−1:30 Std." relative to local time */
|
||||
fun offset(res: Resources, zone: ZoneId, now: Long = System.currentTimeMillis()): String {
|
||||
val i = Instant.ofEpochMilli(now)
|
||||
val diff = Duration.ofSeconds((zone.rules.getOffset(i).totalSeconds - ZoneId.systemDefault().rules.getOffset(i).totalSeconds).toLong())
|
||||
if (diff.isZero) return res.getString(R.string.same_time)
|
||||
val sign = if (diff.isNegative) "−" else "+"
|
||||
val a = diff.abs()
|
||||
val txt = if (a.toMinutesPart() == 0) "${a.toHours()}" else String.format(Locale.getDefault(), "%d:%02d", a.toHours(), a.toMinutesPart())
|
||||
val there = i.atZone(zone).toLocalDate(); val here = i.atZone(ZoneId.systemDefault()).toLocalDate()
|
||||
val day = when { there.isAfter(here) -> res.getString(R.string.tomorrow) + " · "; there.isBefore(here) -> res.getString(R.string.yesterday) + " · "; else -> "" }
|
||||
return day + res.getString(R.string.hours_offset, sign + txt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package app.uhr.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.uhr.ui.theme.WSpace
|
||||
import app.uhr.ui.theme.WType
|
||||
|
||||
/*
|
||||
* Settings in the style of current Pixel system settings: grouped tiles with large outer
|
||||
* corners and small inner ones, 2dp gaps, tonal icon circles, values as summaries,
|
||||
* choices in dialogs.
|
||||
*/
|
||||
|
||||
private val Outer = 24.dp
|
||||
private val Inner = 4.dp
|
||||
|
||||
private fun tileShape(index: Int, count: Int): Shape = when {
|
||||
count == 1 -> RoundedCornerShape(Outer)
|
||||
index == 0 -> RoundedCornerShape(topStart = Outer, topEnd = Outer, bottomStart = Inner, bottomEnd = Inner)
|
||||
index == count - 1 -> RoundedCornerShape(topStart = Inner, topEnd = Inner, bottomStart = Outer, bottomEnd = Outer)
|
||||
else -> RoundedCornerShape(Inner)
|
||||
}
|
||||
|
||||
class GroupScope internal constructor() {
|
||||
internal val items = mutableListOf<@Composable (Shape) -> Unit>()
|
||||
fun item(content: @Composable (Shape) -> Unit) { items += content }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsGroup(title: String? = null, modifier: Modifier = Modifier, content: GroupScope.() -> Unit) {
|
||||
val scope = GroupScope().apply(content)
|
||||
Column(modifier.padding(horizontal = WSpace.s16)) {
|
||||
if (title != null) {
|
||||
Text(
|
||||
title, style = WType.label.copy(fontSize = WType.bodySm.fontSize), color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = WSpace.s16, top = WSpace.s24, bottom = WSpace.s8).semantics { heading() },
|
||||
)
|
||||
} else Spacer(Modifier.padding(top = WSpace.s16))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
scope.items.forEachIndexed { i, item -> item(tileShape(i, scope.items.size)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Tile(shape: Shape, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||
Box(
|
||||
modifier.fillMaxWidth().clip(shape).background(MaterialTheme.colorScheme.surfaceContainerLowest).heightIn(min = 72.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) { content() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowContent(icon: ImageVector?, title: String, summary: String?, trailing: (@Composable () -> Unit)?) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = WSpace.s16, vertical = WSpace.s16), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (icon != null) {
|
||||
Box(Modifier.size(40.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center) {
|
||||
Icon(icon, null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
Spacer(Modifier.width(WSpace.s16))
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = WType.body, color = MaterialTheme.colorScheme.onSurface)
|
||||
if (summary != null) Text(summary, style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
if (trailing != null) { Spacer(Modifier.width(WSpace.s12)); trailing() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsItem(shape: Shape, title: String, summary: String? = null, icon: ImageVector? = null, onClick: (() -> Unit)? = null, trailing: (@Composable () -> Unit)? = null) {
|
||||
Tile(shape, if (onClick != null) Modifier.clip(shape).clickable(onClick = onClick) else Modifier) { RowContent(icon, title, summary, trailing) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsSwitch(shape: Shape, title: String, checked: Boolean, onChange: (Boolean) -> Unit, summary: String? = null, icon: ImageVector? = null) {
|
||||
Tile(shape, Modifier.clip(shape).toggleable(checked, role = Role.Switch, onValueChange = onChange)) {
|
||||
RowContent(icon, title, summary) {
|
||||
Switch(
|
||||
checked, onCheckedChange = null,
|
||||
thumbContent = if (checked) { { Icon(Icons.Rounded.Check, null, Modifier.size(SwitchDefaults.IconSize)) } } else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A tile whose value is picked from a list in a dialog, like Pixel's radio settings. */
|
||||
@Composable
|
||||
fun <T> SettingsChoice(shape: Shape, title: String, options: List<T>, selected: T, label: @Composable (T) -> String, onPick: (T) -> Unit, icon: ImageVector? = null) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
SettingsItem(shape, title, label(selected), icon, onClick = { open = true })
|
||||
if (open) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { open = false },
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(Modifier.selectableGroup()) {
|
||||
options.forEach { o ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp))
|
||||
.selectable(o == selected, role = Role.RadioButton) { onPick(o); open = false }
|
||||
.padding(vertical = WSpace.s12, horizontal = WSpace.s4),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(o == selected, onClick = null)
|
||||
Spacer(Modifier.width(WSpace.s16))
|
||||
Text(label(o), style = WType.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { open = false }) { Text(androidx.compose.ui.res.stringResource(android.R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Free content inside a group tile (swatches, sliders). */
|
||||
@Composable
|
||||
fun SettingsCustom(shape: Shape, content: @Composable ColumnScope.() -> Unit) {
|
||||
Tile(shape) { Column(Modifier.fillMaxWidth().padding(WSpace.s16), content = content) }
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package app.uhr.ui.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.media.RingtoneManager
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AlarmOff
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Label
|
||||
import androidx.compose.material.icons.outlined.NotificationsActive
|
||||
import androidx.compose.material.icons.outlined.PauseCircle
|
||||
import androidx.compose.material.icons.outlined.Vibration
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material.icons.rounded.ExpandLess
|
||||
import androidx.compose.material.icons.rounded.ExpandMore
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TimeInput
|
||||
import androidx.compose.material3.TimePicker
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.uhr.R
|
||||
import app.uhr.data.Alarm
|
||||
import app.uhr.data.AlarmWithInstance
|
||||
import app.uhr.data.InstanceState
|
||||
import app.uhr.data.Settings
|
||||
import app.uhr.ui.components.Fmt
|
||||
import app.uhr.ui.theme.WRadius
|
||||
import app.uhr.ui.theme.WSpace
|
||||
import app.uhr.ui.theme.WType
|
||||
import app.uhr.ui.theme.tabular
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/** Actions the alarm screen needs; MainActivity passes the repository, tests pass fakes. */
|
||||
interface AlarmActions {
|
||||
fun save(alarm: Alarm)
|
||||
fun delete(alarm: Alarm)
|
||||
fun restore(alarm: Alarm)
|
||||
fun dismissNext(alarm: Alarm)
|
||||
}
|
||||
|
||||
/** S01 + S02: list of alarms, each card expands to edit in place. */
|
||||
@Composable
|
||||
fun AlarmScreen(
|
||||
alarms: List<AlarmWithInstance>,
|
||||
settings: Settings,
|
||||
actions: AlarmActions,
|
||||
snackbar: SnackbarHostState,
|
||||
contentPadding: PaddingValues,
|
||||
initiallyExpanded: Long? = null,
|
||||
) {
|
||||
var expanded by rememberSaveable { mutableStateOf(initiallyExpanded) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val res = LocalContext.current.resources
|
||||
if (alarms.isEmpty()) {
|
||||
Column(Modifier.fillMaxSize().padding(contentPadding), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||
Icon(Icons.Outlined.AlarmOff, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.height(WSpace.s12))
|
||||
Text(stringResource(R.string.no_alarms), style = WType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
return
|
||||
}
|
||||
val bedtime = alarms.filter { it.alarm.isBedtimeWake }
|
||||
val others = alarms.filter { !it.alarm.isBedtimeWake }
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = WSpace.s16, end = WSpace.s16, top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding() + 120.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(WSpace.s8),
|
||||
) {
|
||||
if (bedtime.isNotEmpty()) item(key = "h-bed") { SectionHeader(stringResource(R.string.wake_up)) }
|
||||
items(bedtime, key = { it.alarm.id }) { a ->
|
||||
AlarmCard(a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions, onDelete = null)
|
||||
}
|
||||
if (bedtime.isNotEmpty() && others.isNotEmpty()) item(key = "h-other") { SectionHeader(stringResource(R.string.other_alarms)) }
|
||||
items(others, key = { it.alarm.id }) { a ->
|
||||
AlarmCard(
|
||||
a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions,
|
||||
onDelete = {
|
||||
actions.delete(a.alarm)
|
||||
expanded = null
|
||||
scope.launch {
|
||||
val r = snackbar.showSnackbar(res.getString(R.string.alarm_deleted), res.getString(R.string.undo), duration = SnackbarDuration.Short)
|
||||
if (r == SnackbarResult.ActionPerformed) actions.restore(a.alarm)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) =
|
||||
Text(text, style = WType.label, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = WSpace.s8, top = WSpace.s12, bottom = WSpace.s4))
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AlarmCard(
|
||||
item: AlarmWithInstance,
|
||||
settings: Settings,
|
||||
expanded: Boolean,
|
||||
onToggleExpand: () -> Unit,
|
||||
actions: AlarmActions,
|
||||
onDelete: (() -> Unit)?,
|
||||
) {
|
||||
val a = item.alarm
|
||||
val inst = item.instance
|
||||
val res = LocalContext.current.resources
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var pickTime by remember { mutableStateOf(false) }
|
||||
var pickSound by remember { mutableStateOf(false) }
|
||||
var pickPause by remember { mutableStateOf(false) }
|
||||
val timeColor = if (a.enabled) scheme.onSurface else scheme.onSurfaceVariant.copy(alpha = .7f)
|
||||
val status = when {
|
||||
!a.enabled -> if (a.repeats) Fmt.days(res, a.days, settings.weekStart) else stringResource(R.string.not_scheduled)
|
||||
inst?.state == InstanceState.SNOOZED -> stringResource(R.string.snoozed_until, Fmt.clock(inst.fireAt))
|
||||
a.skipUntil != null && inst != null -> stringResource(R.string.paused_until, Fmt.ringDay(res, inst.fireAt))
|
||||
a.repeats -> Fmt.days(res, a.days, settings.weekStart)
|
||||
inst != null -> Fmt.ringDay(res, inst.fireAt)
|
||||
else -> ""
|
||||
}
|
||||
Column(
|
||||
Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow)
|
||||
.clickable(onClick = onToggleExpand).padding(WSpace.s20).animateContentSize(spring(dampingRatio = .85f, stiffness = 380f))
|
||||
.semantics(mergeDescendants = !expanded) {
|
||||
contentDescription = listOf(a.label, Fmt.time(a.hour, a.minute), status).filter { it.isNotBlank() }.joinToString(", ")
|
||||
stateDescription = res.getString(if (a.enabled) R.string.on else R.string.off)
|
||||
},
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (expanded) {
|
||||
LabelField(a.label) { actions.save(a.copy(label = it)) }
|
||||
} else {
|
||||
Text(a.label, style = WType.label, color = scheme.onSurfaceVariant, modifier = Modifier.weight(1f))
|
||||
}
|
||||
Box(Modifier.size(28.dp).clip(CircleShape).background(scheme.surfaceContainerHigh), contentAlignment = Alignment.Center) {
|
||||
Icon(if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, null, Modifier.size(20.dp), tint = scheme.onSurface)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(WSpace.s8))
|
||||
Row(Modifier.clip(RoundedCornerShape(WRadius.sm)).clickable { pickTime = true }, verticalAlignment = Alignment.Bottom) {
|
||||
Text(Fmt.time(a.hour, a.minute), style = tabular(WType.alarmTime), color = timeColor)
|
||||
Fmt.ampm(a.hour)?.let { Text(" $it", style = WType.alarmAmpm, color = timeColor, modifier = Modifier.padding(bottom = 6.dp)) }
|
||||
}
|
||||
Spacer(Modifier.height(WSpace.s8))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(status, style = WType.bodySm, color = scheme.onSurface, modifier = Modifier.weight(1f))
|
||||
Switch(a.enabled, onCheckedChange = { actions.save(a.copy(enabled = it, skipUntil = null)) })
|
||||
}
|
||||
AnimatedVisibility(expanded) {
|
||||
Column {
|
||||
Spacer(Modifier.height(WSpace.s12))
|
||||
DayChips(a, settings.weekStart) { actions.save(it) }
|
||||
Spacer(Modifier.height(WSpace.s8))
|
||||
if (a.repeats) OptionRow(Icons.Outlined.PauseCircle, if (a.skipUntil != null) stringResource(R.string.paused_until_date, a.skipUntil.toString()) else stringResource(R.string.pause_alarm)) { pickPause = true }
|
||||
if (inst != null && a.enabled && inst.state == InstanceState.SCHEDULED) OptionRow(Icons.Outlined.AlarmOff, stringResource(R.string.skip_next)) { actions.dismissNext(a) }
|
||||
OptionRow(Icons.Outlined.NotificationsActive, soundName(a.ringtoneUri)) { pickSound = true }
|
||||
OptionRow(Icons.Outlined.Vibration, stringResource(R.string.vibrate), trailing = { if (a.vibrate) Icon(Icons.Rounded.Check, null, tint = scheme.primary) }) {
|
||||
actions.save(a.copy(vibrate = !a.vibrate))
|
||||
}
|
||||
if (onDelete != null) OptionRow(Icons.Outlined.Delete, stringResource(R.string.delete), onClick = onDelete)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pickTime) TimeDialog(a.hour, a.minute, onDismiss = { pickTime = false }) { h, m -> pickTime = false; actions.save(a.copy(hour = h, minute = m, enabled = true)) }
|
||||
if (pickSound) SoundDialog(a.ringtoneUri, onDismiss = { pickSound = false }) { uri -> pickSound = false; actions.save(a.copy(ringtoneUri = uri)) }
|
||||
if (pickPause) {
|
||||
val state = rememberDatePickerState(
|
||||
initialSelectedDateMillis = (a.skipUntil ?: LocalDate.now().plusDays(1)).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(),
|
||||
)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { pickPause = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
pickPause = false
|
||||
state.selectedDateMillis?.let { actions.save(a.copy(skipUntil = Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate(), enabled = true)) }
|
||||
}) { Text(stringResource(android.R.string.ok)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pickPause = false; actions.save(a.copy(skipUntil = null)) }) { Text(stringResource(R.string.resume)) }
|
||||
},
|
||||
) { DatePicker(state, title = { Text(stringResource(R.string.pause_until), Modifier.padding(start = 24.dp, top = 16.dp)) }) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun androidx.compose.foundation.layout.RowScope.LabelField(value: String, onDone: (String) -> Unit) {
|
||||
var text by remember(value) { mutableStateOf(value) }
|
||||
TextField(
|
||||
text, { text = it }, Modifier.weight(1f),
|
||||
placeholder = { Text(stringResource(R.string.add_label)) },
|
||||
leadingIcon = { Icon(Icons.Outlined.Label, null) },
|
||||
singleLine = true, textStyle = WType.body,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { onDone(text.trim()) }),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = androidx.compose.ui.graphics.Color.Transparent, unfocusedContainerColor = androidx.compose.ui.graphics.Color.Transparent,
|
||||
unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
|
||||
),
|
||||
)
|
||||
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { if (text.trim() != value) onDone(text.trim()) } }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayChips(a: Alarm, weekStart: Int, onChange: (Alarm) -> Unit) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Fmt.orderedDays(weekStart).forEach { d ->
|
||||
val on = a.ringsOn(d)
|
||||
Box(
|
||||
Modifier.size(38.dp).clip(CircleShape)
|
||||
.background(if (on) scheme.primary else androidx.compose.ui.graphics.Color.Transparent)
|
||||
.border(1.dp, if (on) scheme.primary else scheme.outline, CircleShape)
|
||||
.clickable(role = Role.Checkbox) { onChange(a.withDay(d, !on)) }
|
||||
.semantics { contentDescription = Fmt.dayFull(d) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text(Fmt.dayNarrow(d), style = WType.label, color = if (on) scheme.onPrimary else scheme.onSurface) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OptionRow(icon: ImageVector, text: String, trailing: @Composable (() -> Unit)? = null, onClick: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.sm)).clickable(onClick = onClick).padding(vertical = WSpace.s12, horizontal = WSpace.s4),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.width(WSpace.s16))
|
||||
Text(text, style = WType.bodySm, modifier = Modifier.weight(1f))
|
||||
trailing?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun soundName(uri: String?): String {
|
||||
val context = LocalContext.current
|
||||
return when (uri) {
|
||||
null -> stringResource(R.string.sound_default)
|
||||
Alarm.SILENT -> stringResource(R.string.sound_silent)
|
||||
else -> remember(uri) {
|
||||
runCatching { RingtoneManager.getRingtone(context, Uri.parse(uri))?.getTitle(context) }.getOrNull()
|
||||
?: Uri.parse(uri).lastPathSegment ?: uri
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* S03: M3 time picker with dial/keyboard switch. 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
|
||||
}
|
||||
var preview by remember { mutableStateOf<android.media.Ringtone?>(null) }
|
||||
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { preview?.stop() } }
|
||||
fun play(uri: String?) {
|
||||
preview?.stop()
|
||||
val u = when (uri) { null -> Uri.parse("android.resource://${context.packageName}/${R.raw.alarm_default}"); Alarm.SILENT -> null; else -> Uri.parse(uri) }
|
||||
preview = u?.let { RingtoneManager.getRingtone(context, it) }?.also { it.play() }
|
||||
}
|
||||
val file = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri != null) {
|
||||
runCatching { context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) }
|
||||
onPick(uri.toString())
|
||||
}
|
||||
}
|
||||
var selected by remember { mutableStateOf(current) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.alarm_sound)) },
|
||||
text = {
|
||||
LazyColumn(Modifier.height(420.dp)) {
|
||||
item { TextButton(onClick = { file.launch(arrayOf("audio/*")) }) { Text(stringResource(R.string.add_own_sound)) } }
|
||||
val rows = listOf<Pair<String?, String>>(null to context.getString(R.string.sound_default), Alarm.SILENT to context.getString(R.string.sound_silent)) + tones
|
||||
items(rows) { (uri, name) ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).clickable { selected = uri; play(uri) }.padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected == uri, onClick = null)
|
||||
Spacer(Modifier.width(WSpace.s12))
|
||||
Text(name, style = WType.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onPick(selected) }) { Text(stringResource(android.R.string.ok)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package app.uhr.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Bedtime
|
||||
import androidx.compose.material.icons.outlined.NotificationsActive
|
||||
import androidx.compose.material.icons.outlined.WbSunny
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.uhr.R
|
||||
import app.uhr.data.Alarm
|
||||
import app.uhr.data.Bedtime
|
||||
import app.uhr.data.Settings
|
||||
import app.uhr.ui.components.Fmt
|
||||
import app.uhr.ui.components.SettingsChoice
|
||||
import app.uhr.ui.components.SettingsGroup
|
||||
import app.uhr.ui.components.SettingsSwitch
|
||||
import app.uhr.ui.theme.WRadius
|
||||
import app.uhr.ui.theme.WSpace
|
||||
import app.uhr.ui.theme.WType
|
||||
import app.uhr.ui.theme.tabular
|
||||
|
||||
/** S10: bedtime and wake-up, with the expected sleep; the wake-up becomes the "Aufwachen" alarm. */
|
||||
@Composable
|
||||
fun BedtimeScreen(b: Bedtime, settings: Settings, onSave: (Bedtime) -> Unit, contentPadding: PaddingValues) {
|
||||
var pickBed by remember { mutableStateOf(false) }
|
||||
var pickWake by remember { mutableStateOf(false) }
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
if (!b.enabled && b == Bedtime()) {
|
||||
Column(Modifier.fillMaxSize().padding(contentPadding).padding(WSpace.s32), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||
Icon(Icons.Outlined.Bedtime, null, Modifier.size(72.dp), tint = scheme.primary)
|
||||
Spacer(Modifier.height(WSpace.s16))
|
||||
Text(stringResource(R.string.bedtime_intro_title), style = WType.headline, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(WSpace.s8))
|
||||
Text(stringResource(R.string.bedtime_intro), style = WType.body, color = scheme.onSurfaceVariant, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(WSpace.s24))
|
||||
Button(onClick = { onSave(b.copy(enabled = true)) }) { Text(stringResource(R.string.get_started)) }
|
||||
}
|
||||
return
|
||||
}
|
||||
val sleepMin = ((b.wakeHour * 60 + b.wakeMinute) - (b.bedHour * 60 + b.bedMinute) + 1440) % 1440
|
||||
Column(Modifier.fillMaxSize().padding(contentPadding).verticalScroll(rememberScrollState())) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = WSpace.s16, vertical = WSpace.s8), horizontalArrangement = Arrangement.spacedBy(WSpace.s8)) {
|
||||
TimeTile(Icons.Outlined.Bedtime, stringResource(R.string.bedtime), Fmt.time(b.bedHour, b.bedMinute), Modifier.weight(1f)) { pickBed = true }
|
||||
TimeTile(Icons.Outlined.WbSunny, stringResource(R.string.wake_up), Fmt.time(b.wakeHour, b.wakeMinute), Modifier.weight(1f)) { pickWake = true }
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.sleep_duration, sleepMin / 60, sleepMin % 60), style = WType.body, color = scheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth().padding(WSpace.s8), textAlign = TextAlign.Center,
|
||||
)
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = WSpace.s24, vertical = WSpace.s8), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Fmt.orderedDays(settings.weekStart).forEach { d ->
|
||||
val on = b.days and (1 shl (d.value - 1)) != 0
|
||||
Box(
|
||||
Modifier.size(38.dp).clip(CircleShape).background(if (on) scheme.primary else Color.Transparent)
|
||||
.border(1.dp, if (on) scheme.primary else scheme.outline, CircleShape)
|
||||
.clickable(role = Role.Checkbox) { onSave(b.copy(days = if (on) b.days and (1 shl (d.value - 1)).inv() else b.days or (1 shl (d.value - 1)))) }
|
||||
.semantics { contentDescription = Fmt.dayFull(d) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text(Fmt.dayNarrow(d), style = WType.label, color = if (on) scheme.onPrimary else scheme.onSurface) }
|
||||
}
|
||||
}
|
||||
SettingsGroup {
|
||||
item { SettingsSwitch(it, stringResource(R.string.bedtime_schedule), b.enabled, { v -> onSave(b.copy(enabled = v)) }, icon = Icons.Outlined.Bedtime) }
|
||||
item {
|
||||
val options = listOf(-1, 0, 15, 30, 60)
|
||||
SettingsChoice(it, stringResource(R.string.bedtime_reminder), options, b.reminderMin,
|
||||
{ m -> if (m < 0) stringResource(R.string.off) else if (m == 0) stringResource(R.string.at_bedtime) else stringResource(R.string.minutes_before, m) },
|
||||
{ m -> onSave(b.copy(reminderMin = m)) }, Icons.Outlined.NotificationsActive)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(120.dp))
|
||||
}
|
||||
if (pickBed) TimeDialog(b.bedHour, b.bedMinute, { pickBed = false }) { h, m -> pickBed = false; onSave(b.copy(bedHour = h, bedMinute = m)) }
|
||||
if (pickWake) TimeDialog(b.wakeHour, b.wakeMinute, { pickWake = false }) { h, m -> pickWake = false; onSave(b.copy(wakeHour = h, wakeMinute = m)) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimeTile(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, time: String, modifier: Modifier, onClick: () -> Unit) {
|
||||
Column(
|
||||
modifier.clip(RoundedCornerShape(WRadius.card)).background(MaterialTheme.colorScheme.surfaceContainerLow).clickable(onClick = onClick).padding(WSpace.s20),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Text(" $label", style = WType.label, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Spacer(Modifier.height(WSpace.s8))
|
||||
Text(time, style = tabular(WType.timerTime))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package app.uhr.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Alarm
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.uhr.R
|
||||
import app.uhr.data.AlarmInstance
|
||||
import app.uhr.data.City
|
||||
import app.uhr.data.ClockStyle
|
||||
import app.uhr.data.Settings
|
||||
import app.uhr.data.ZoneHit
|
||||
import app.uhr.data.Zones
|
||||
import app.uhr.ui.components.AnalogClock
|
||||
import app.uhr.ui.components.Fmt
|
||||
import app.uhr.ui.theme.WRadius
|
||||
import app.uhr.ui.theme.WSpace
|
||||
import app.uhr.ui.theme.WType
|
||||
import app.uhr.ui.theme.tabular
|
||||
import kotlinx.coroutines.delay
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
|
||||
/** S06: local time and the world clock. [nowOverride] pins the time for screenshot tests. */
|
||||
@Composable
|
||||
fun ClockScreen(
|
||||
settings: Settings,
|
||||
cities: List<City>,
|
||||
next: AlarmInstance?,
|
||||
contentPadding: PaddingValues,
|
||||
onDeleteCity: (City) -> Unit,
|
||||
nowOverride: Long? = null,
|
||||
) {
|
||||
val tick = remember { mutableLongStateOf(nowOverride ?: System.currentTimeMillis()) }
|
||||
if (nowOverride == null) LaunchedEffect(settings.showSeconds) {
|
||||
while (true) { tick.longValue = System.currentTimeMillis(); delay(if (settings.showSeconds) 1000 else 1000 - tick.longValue % 1000) }
|
||||
}
|
||||
val now = tick.longValue
|
||||
val res = LocalContext.current.resources
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = WSpace.s16, end = WSpace.s16, top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding() + 120.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(WSpace.s8),
|
||||
) {
|
||||
item(key = "header") {
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = WSpace.s24), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
if (settings.clockStyle == ClockStyle.ANALOG) {
|
||||
AnalogClock(
|
||||
Instant.ofEpochMilli(now).atZone(ZoneId.systemDefault()), 240.dp,
|
||||
face = scheme.surfaceContainerLow, hourHand = scheme.primary, minuteHand = scheme.onSurface,
|
||||
secondHand = if (settings.showSeconds) scheme.tertiary else null,
|
||||
)
|
||||
} else {
|
||||
Text(Fmt.clock(now, seconds = settings.showSeconds), style = tabular(WType.clockBig), color = scheme.onSurface)
|
||||
}
|
||||
Spacer(Modifier.height(WSpace.s8))
|
||||
Text(Fmt.longDate(now), style = WType.body, color = scheme.onSurfaceVariant)
|
||||
if (next != null) Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = WSpace.s4)) {
|
||||
Icon(Icons.Outlined.Alarm, null, Modifier.size(16.dp), tint = scheme.onSurfaceVariant)
|
||||
Text(" " + Fmt.ringDay(res, next.fireAt) + ", " + Fmt.clock(next.fireAt), style = WType.bodySm, color = scheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(cities, key = { it.id }) { city -> CityRow(city, now, settings) { onDeleteCity(city) } }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun CityRow(city: City, now: Long, settings: Settings, onDelete: () -> Unit) {
|
||||
val res = LocalContext.current.resources
|
||||
val zone = remember(city.zoneId) { ZoneId.of(city.zoneId) }
|
||||
val state = rememberSwipeToDismissBoxState()
|
||||
LaunchedEffect(state.currentValue) { if (state.currentValue != SwipeToDismissBoxValue.Settled) onDelete() }
|
||||
SwipeToDismissBox(
|
||||
state,
|
||||
backgroundContent = {
|
||||
Box(Modifier.fillMaxSize().clip(RoundedCornerShape(WRadius.card)).background(MaterialTheme.colorScheme.errorContainer).padding(horizontal = 24.dp), contentAlignment = Alignment.CenterEnd) {
|
||||
Icon(Icons.Rounded.Delete, stringResource(R.string.delete), tint = MaterialTheme.colorScheme.onErrorContainer)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(MaterialTheme.colorScheme.surfaceContainerLow).padding(WSpace.s20),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(city.name, style = WType.title)
|
||||
Text(Fmt.offset(res, zone, now), style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Text(Fmt.clock(now, zone, settings.showSeconds), style = tabular(WType.timerTime))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** S07: offline city search. */
|
||||
@Composable
|
||||
fun AddCityDialog(onDismiss: () -> Unit, onPick: (ZoneHit) -> Unit) {
|
||||
var q by remember { mutableStateOf("") }
|
||||
val hits = remember(q) { Zones.search(q) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.add_city)) },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(q, { q = it }, Modifier.fillMaxWidth(), placeholder = { Text(stringResource(R.string.search_city)) },
|
||||
leadingIcon = { Icon(Icons.Rounded.Search, null) }, singleLine = true, shape = RoundedCornerShape(WRadius.pill))
|
||||
LazyColumn(Modifier.height(360.dp).padding(top = WSpace.s8)) {
|
||||
items(hits, key = { it.zoneId }) { h ->
|
||||
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).clickable { onPick(h) }.padding(vertical = 10.dp, horizontal = 4.dp)) {
|
||||
Text(h.city, style = WType.body)
|
||||
Text(h.zoneId.replace('_', ' '), style = WType.labelSm, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package app.uhr.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.AccessTime
|
||||
import androidx.compose.material.icons.outlined.CalendarMonth
|
||||
import androidx.compose.material.icons.outlined.DarkMode
|
||||
import androidx.compose.material.icons.outlined.Snooze
|
||||
import androidx.compose.material.icons.outlined.TrendingUp
|
||||
import androidx.compose.material.icons.outlined.Vibration
|
||||
import androidx.compose.material.icons.outlined.VolumeOff
|
||||
import androidx.compose.material.icons.outlined.VolumeUp
|
||||
import androidx.compose.material.icons.outlined.Watch
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LargeTopAppBar
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.uhr.R
|
||||
import app.uhr.data.ClockStyle
|
||||
import app.uhr.data.Settings
|
||||
import app.uhr.data.ThemeMode
|
||||
import app.uhr.data.VolumeButtons
|
||||
import app.uhr.ui.components.Fmt
|
||||
import app.uhr.ui.components.SettingsChoice
|
||||
import app.uhr.ui.components.SettingsGroup
|
||||
import app.uhr.ui.components.SettingsSwitch
|
||||
import java.time.DayOfWeek
|
||||
|
||||
/** S13 */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(s: Settings, onChange: ((Settings) -> Settings) -> Unit, onBack: () -> Unit) {
|
||||
val bar = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||
val minutes = @Composable { m: Int -> if (m == 0) stringResource(R.string.never) else stringResource(R.string.n_minutes, m) }
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(bar.nestedScrollConnection),
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
topBar = {
|
||||
LargeTopAppBar(
|
||||
title = { Text(stringResource(R.string.settings)) },
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.back)) } },
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainer, scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer),
|
||||
scrollBehavior = bar,
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
Column(Modifier.padding(pad).fillMaxSize().verticalScroll(rememberScrollState()).navigationBarsPadding()) {
|
||||
SettingsGroup(stringResource(R.string.alarms)) {
|
||||
item { SettingsChoice(it, stringResource(R.string.silence_after), listOf(1, 5, 10, 15, 20, 25, 0), s.silenceAfterMin, { m -> minutes(m) }, { v -> onChange { x -> x.copy(silenceAfterMin = v) } }, Icons.Outlined.VolumeOff) }
|
||||
item { SettingsChoice(it, stringResource(R.string.snooze_length), (1..30).toList(), s.snoozeMin, { m -> stringResource(R.string.n_minutes, m) }, { v -> onChange { x -> x.copy(snoozeMin = v) } }, Icons.Outlined.Snooze) }
|
||||
item { SettingsChoice(it, stringResource(R.string.alarm_volume), listOf(20, 40, 60, 80, 100), s.alarmVolume, { v -> "$v %" }, { v -> onChange { x -> x.copy(alarmVolume = v) } }, Icons.Outlined.VolumeUp) }
|
||||
item { SettingsChoice(it, stringResource(R.string.gradual_volume), listOf(0, 5, 10, 15, 20, 30, 60), s.gradualVolumeS, { v -> if (v == 0) stringResource(R.string.off) else stringResource(R.string.n_seconds, v) }, { v -> onChange { x -> x.copy(gradualVolumeS = v) } }, Icons.Outlined.TrendingUp) }
|
||||
item {
|
||||
val labels = mapOf(VolumeButtons.VOLUME to R.string.vb_volume, VolumeButtons.SNOOZE to R.string.snooze, VolumeButtons.STOP to R.string.stop, VolumeButtons.NOTHING to R.string.vb_nothing)
|
||||
SettingsChoice(it, stringResource(R.string.volume_buttons), VolumeButtons.entries, s.volumeButtons, { v -> stringResource(labels.getValue(v)) }, { v -> onChange { x -> x.copy(volumeButtons = v) } }, Icons.Outlined.VolumeUp)
|
||||
}
|
||||
item { SettingsChoice(it, stringResource(R.string.week_start), listOf(1, 6, 7), s.weekStart, { d -> Fmt.dayFull(DayOfWeek.of(d)) }, { v -> onChange { x -> x.copy(weekStart = v) } }, Icons.Outlined.CalendarMonth) }
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.timer)) {
|
||||
item { SettingsSwitch(it, stringResource(R.string.timer_vibrate), s.timerVibrate, { v -> onChange { x -> x.copy(timerVibrate = v) } }, icon = Icons.Outlined.Vibration) }
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.clock)) {
|
||||
item { SettingsChoice(it, stringResource(R.string.style), ClockStyle.entries, s.clockStyle, { v -> stringResource(if (v == ClockStyle.DIGITAL) R.string.digital else R.string.analog) }, { v -> onChange { x -> x.copy(clockStyle = v) } }, Icons.Outlined.Watch) }
|
||||
item { SettingsSwitch(it, stringResource(R.string.show_seconds), s.showSeconds, { v -> onChange { x -> x.copy(showSeconds = v) } }, icon = Icons.Outlined.AccessTime) }
|
||||
item {
|
||||
val labels = mapOf(ThemeMode.SYSTEM to R.string.theme_system, ThemeMode.LIGHT to R.string.theme_light, ThemeMode.DARK to R.string.theme_dark)
|
||||
SettingsChoice(it, stringResource(R.string.theme), ThemeMode.entries, s.theme, { v -> stringResource(labels.getValue(v)) }, { v -> onChange { x -> x.copy(theme = v) } }, Icons.Outlined.DarkMode)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(32.dp()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.dp() = androidx.compose.ui.unit.Dp(this.toFloat())
|
||||
@@ -0,0 +1,74 @@
|
||||
package app.uhr.ui.screens
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.withFrameMillis
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.uhr.R
|
||||
import app.uhr.data.Stopwatch
|
||||
import app.uhr.data.StopwatchState
|
||||
import app.uhr.ui.components.Fmt
|
||||
import app.uhr.ui.theme.WSpace
|
||||
import app.uhr.ui.theme.WType
|
||||
import app.uhr.ui.theme.tabular
|
||||
|
||||
/** S09: big time, laps, three large pill buttons at the bottom. */
|
||||
@Composable
|
||||
fun StopwatchScreen(sw: Stopwatch, onToggle: () -> Unit, onLap: () -> Unit, onReset: () -> Unit, contentPadding: PaddingValues, nowOverride: Long? = null) {
|
||||
val tick = remember { mutableLongStateOf(nowOverride ?: SystemClock.elapsedRealtime()) }
|
||||
if (nowOverride == null && sw.state == StopwatchState.RUNNING) LaunchedEffect(sw) { while (true) withFrameMillis { tick.longValue = SystemClock.elapsedRealtime() } }
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val elapsed = sw.elapsed(tick.longValue)
|
||||
Column(Modifier.fillMaxSize().padding(contentPadding).padding(horizontal = WSpace.s16)) {
|
||||
Spacer(Modifier.height(WSpace.s32))
|
||||
Text(Fmt.stopwatch(elapsed), style = tabular(WType.stopwatch), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
|
||||
val laps = sw.laps
|
||||
LazyColumn(Modifier.weight(1f).padding(top = WSpace.s16)) {
|
||||
val splits = laps.mapIndexed { i, total -> total - (laps.getOrNull(i - 1) ?: 0) }
|
||||
val fastest = splits.minOrNull(); val slowest = splits.maxOrNull()
|
||||
itemsIndexed(laps.reversed()) { ri, total ->
|
||||
val i = laps.size - 1 - ri
|
||||
val split = splits[i]
|
||||
val color = when { laps.size < 2 -> scheme.onSurface; split == fastest -> scheme.primary; split == slowest -> scheme.error; else -> scheme.onSurface }
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = WSpace.s8, horizontal = WSpace.s8)) {
|
||||
Text(stringResource(R.string.lap_n, i + 1), style = WType.body, color = color, modifier = Modifier.weight(1f))
|
||||
Text(Fmt.stopwatch(split), style = tabular(WType.body), color = color, modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
|
||||
Text(Fmt.stopwatch(total), style = tabular(WType.body), color = scheme.onSurfaceVariant, modifier = Modifier.weight(1f), textAlign = TextAlign.End)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(WSpace.s8), modifier = Modifier.padding(bottom = WSpace.s16)) {
|
||||
val running = sw.state == StopwatchState.RUNNING
|
||||
Pill(
|
||||
stringResource(if (running) R.string.stop else R.string.start),
|
||||
if (running) scheme.tertiaryContainer else scheme.primaryContainer,
|
||||
if (running) scheme.onTertiaryContainer else scheme.onPrimaryContainer,
|
||||
height = 88, onClick = onToggle,
|
||||
)
|
||||
if (sw.state != StopwatchState.IDLE) {
|
||||
Pill(stringResource(R.string.reset), scheme.surfaceContainerHigh, scheme.onSurface, height = 72, onClick = onReset)
|
||||
if (running) Pill(stringResource(R.string.lap), scheme.surfaceContainerHigh, scheme.onSurface, height = 72, onClick = onLap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package app.uhr.ui.screens
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.Backspace
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Pause
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
import androidx.compose.material.icons.rounded.Replay
|
||||
import androidx.compose.material.icons.rounded.Stop
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.uhr.R
|
||||
import app.uhr.data.Timer
|
||||
import app.uhr.data.TimerState
|
||||
import app.uhr.ui.components.Fmt
|
||||
import app.uhr.ui.theme.WRadius
|
||||
import app.uhr.ui.theme.WSpace
|
||||
import app.uhr.ui.theme.WType
|
||||
import app.uhr.ui.theme.tabular
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
interface TimerActions {
|
||||
fun start(durationMs: Long)
|
||||
fun pause(id: Long)
|
||||
fun resume(id: Long)
|
||||
fun addMinute(id: Long)
|
||||
fun reset(id: Long)
|
||||
fun delete(id: Long)
|
||||
fun stopRinging()
|
||||
}
|
||||
|
||||
/** S08: keypad when there is no timer (or after "+"), cards for running ones. */
|
||||
@Composable
|
||||
fun TimerScreen(
|
||||
timers: List<Timer>,
|
||||
showKeypad: Boolean,
|
||||
digits: String,
|
||||
onDigits: (String) -> Unit,
|
||||
actions: TimerActions,
|
||||
contentPadding: PaddingValues,
|
||||
nowOverride: Long? = null,
|
||||
) {
|
||||
if (timers.isEmpty() || showKeypad) {
|
||||
Keypad(digits, onDigits, Modifier.padding(contentPadding))
|
||||
return
|
||||
}
|
||||
val tick = remember { mutableLongStateOf(nowOverride ?: System.currentTimeMillis()) }
|
||||
if (nowOverride == null) LaunchedEffect(Unit) { while (true) { tick.longValue = System.currentTimeMillis(); delay(100) } }
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = WSpace.s16, end = WSpace.s16, top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding() + 120.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(WSpace.s8),
|
||||
) {
|
||||
items(timers, key = { it.id }) { TimerCard(it, tick.longValue, actions) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimerCard(t: Timer, now: Long, actions: TimerActions) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val remaining = t.remaining(now)
|
||||
val progress = (remaining.toFloat() / t.durationMs).coerceIn(0f, 1f)
|
||||
val blink = rememberInfiniteTransition(label = "blink").animateFloat(1f, .3f, infiniteRepeatable(tween(600), RepeatMode.Reverse), label = "a")
|
||||
val label = t.label.ifBlank { stringResource(R.string.timer_of, Fmt.duration(t.durationMs)) }
|
||||
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow).padding(WSpace.s20)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(label, style = WType.title, modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = { actions.delete(t.id) }) { Icon(Icons.Rounded.Close, stringResource(R.string.delete)) }
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.weight(1f).aspectRatio(1f).padding(WSpace.s8), contentAlignment = Alignment.Center) {
|
||||
val track = scheme.surfaceContainerHigh
|
||||
val ring = scheme.primary
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val w = 6.dp.toPx()
|
||||
drawArc(track, 0f, 360f, false, style = Stroke(w))
|
||||
drawArc(ring, -90f, 360f * progress, false, style = Stroke(w, cap = StrokeCap.Round))
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
(if (remaining < 0) "−" else "") + Fmt.duration(remaining + if (remaining > 0) 999 else 0),
|
||||
style = tabular(WType.timerTime),
|
||||
modifier = Modifier.alpha(if (t.state == TimerState.PAUSED || t.state == TimerState.RINGING) blink.value else 1f)
|
||||
.semantics { contentDescription = Fmt.duration(remaining) },
|
||||
)
|
||||
IconButton(onClick = { actions.reset(t.id) }) { Icon(Icons.Rounded.Replay, stringResource(R.string.reset)) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(WSpace.s16))
|
||||
Column(Modifier.width(120.dp), verticalArrangement = Arrangement.spacedBy(WSpace.s12)) {
|
||||
Pill(stringResource(R.string.plus_minute), scheme.surfaceContainerHigh, scheme.onSurface) { actions.addMinute(t.id) }
|
||||
when (t.state) {
|
||||
TimerState.RUNNING -> PillIcon(Icons.Rounded.Pause, stringResource(R.string.pause), app.uhr.ui.theme.fabColors().first, app.uhr.ui.theme.fabColors().second) { actions.pause(t.id) }
|
||||
TimerState.PAUSED -> PillIcon(Icons.Rounded.PlayArrow, stringResource(R.string.start), app.uhr.ui.theme.fabColors().first, app.uhr.ui.theme.fabColors().second) { actions.resume(t.id) }
|
||||
TimerState.RINGING -> PillIcon(Icons.Rounded.Stop, stringResource(R.string.stop), scheme.tertiaryContainer, scheme.onTertiaryContainer) { actions.stopRinging() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Pill(text: String, bg: androidx.compose.ui.graphics.Color, fg: androidx.compose.ui.graphics.Color, modifier: Modifier = Modifier, height: Int = 56, onClick: () -> Unit) {
|
||||
Box(modifier.fillMaxWidth().height(height.dp).clip(RoundedCornerShape(WRadius.pill)).background(bg).clickable(onClick = onClick), contentAlignment = Alignment.Center) {
|
||||
Text(text, style = if (height >= 72) WType.title else WType.label, color = fg)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PillIcon(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, bg: androidx.compose.ui.graphics.Color, fg: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {
|
||||
Box(Modifier.fillMaxWidth().height(64.dp).clip(RoundedCornerShape(WRadius.pill)).background(bg).clickable(onClick = onClick), contentAlignment = Alignment.Center) {
|
||||
Icon(icon, label, tint = fg)
|
||||
}
|
||||
}
|
||||
|
||||
/** Digits fill from the right: "130" = 1 min 30 s. Max 6 digits (99h 59m 59s). */
|
||||
fun digitsToMs(d: String): Long {
|
||||
val p = d.padStart(6, '0')
|
||||
return ((p.substring(0, 2).toLong() * 3600) + p.substring(2, 4).toLong() * 60 + p.substring(4, 6).toLong()) * 1000
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Keypad(digits: String, onDigits: (String) -> Unit, modifier: Modifier) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val p = digits.padStart(6, '0')
|
||||
Column(modifier.fillMaxSize().padding(horizontal = WSpace.s24), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||
val active = scheme.primary; val idle = scheme.onSurfaceVariant
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
listOf(p.substring(0, 2) to "h", p.substring(2, 4) to "m", p.substring(4, 6) to "s").forEachIndexed { i, (v, u) ->
|
||||
val c = if (digits.length > 4 - i * 2) active else idle
|
||||
withStyle(SpanStyle(color = c, fontSize = WType.clockBig.fontSize * .7f)) { append(v) }
|
||||
withStyle(SpanStyle(color = c, fontSize = WType.title.fontSize)) { append(u + " ") }
|
||||
}
|
||||
},
|
||||
style = tabular(WType.clockBig),
|
||||
)
|
||||
Spacer(Modifier.height(WSpace.s32))
|
||||
val keys = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "00", "0", "⌫")
|
||||
keys.chunked(3).forEach { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(WSpace.s16), modifier = Modifier.padding(vertical = WSpace.s8)) {
|
||||
row.forEach { k ->
|
||||
Box(
|
||||
Modifier.size(80.dp).clip(CircleShape).background(if (k == "⌫") scheme.secondaryContainer else scheme.surfaceContainerHigh)
|
||||
.clickable {
|
||||
onDigits(
|
||||
when (k) {
|
||||
"⌫" -> digits.dropLast(1)
|
||||
else -> (digits + k).trimStart('0').take(6)
|
||||
}
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (k == "⌫") Icon(Icons.AutoMirrored.Rounded.Backspace, stringResource(R.string.backspace))
|
||||
else Text(k, style = WType.headline)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package app.uhr.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import app.uhr.data.ThemeMode
|
||||
|
||||
private val Light = lightColorScheme(
|
||||
primary = WColor.accent, onPrimary = WColor.onAccent,
|
||||
primaryContainer = WColor.accentContainer, onPrimaryContainer = WColor.onAccentContainer,
|
||||
secondaryContainer = WColor.navIndicator, onSecondaryContainer = WColor.text,
|
||||
tertiaryContainer = WColor.stop, onTertiaryContainer = WColor.onStop,
|
||||
background = WColor.bg, onBackground = WColor.text,
|
||||
surface = WColor.bg, onSurface = WColor.text, onSurfaceVariant = WColor.textMuted,
|
||||
surfaceContainer = WColor.nav, surfaceContainerLow = WColor.card, surfaceContainerHigh = WColor.cardHigh,
|
||||
surfaceContainerLowest = WColor.surface,
|
||||
outline = WColor.borderInput, outlineVariant = WColor.border, error = WColor.danger,
|
||||
)
|
||||
|
||||
private val Dark = darkColorScheme(
|
||||
primary = WColor.darkAccent, onPrimary = WColor.darkOnAccent,
|
||||
primaryContainer = WColor.darkAccentContainer, onPrimaryContainer = WColor.darkOnAccentContainer,
|
||||
secondaryContainer = WColor.darkNavIndicator, onSecondaryContainer = WColor.darkText,
|
||||
tertiaryContainer = WColor.darkStop, onTertiaryContainer = WColor.darkOnStop,
|
||||
background = WColor.darkBg, onBackground = WColor.darkText,
|
||||
surface = WColor.darkBg, onSurface = WColor.darkText, onSurfaceVariant = WColor.darkTextMuted,
|
||||
surfaceContainer = WColor.darkNav, surfaceContainerLow = WColor.darkCard, surfaceContainerHigh = WColor.darkCardHigh,
|
||||
surfaceContainerLowest = WColor.darkCardHigh,
|
||||
outline = WColor.darkBorderInput, outlineVariant = WColor.darkBorder, error = WColor.darkDanger,
|
||||
)
|
||||
|
||||
private val AppTypography = Typography(
|
||||
// displayLarge/headline stay M3 defaults: the time picker uses them for its hour and minute fields
|
||||
titleLarge = WType.title,
|
||||
bodyLarge = WType.body, bodyMedium = WType.bodySm, labelLarge = WType.label, labelMedium = WType.label, labelSmall = WType.labelSm,
|
||||
)
|
||||
|
||||
/** Tabular figures so times do not jump while they tick. */
|
||||
fun tabular(style: androidx.compose.ui.text.TextStyle) = style.copy(fontFeatureSettings = "tnum")
|
||||
|
||||
@Composable
|
||||
fun ClockTheme(mode: ThemeMode = ThemeMode.SYSTEM, content: @Composable () -> Unit) {
|
||||
val dark = when (mode) { ThemeMode.SYSTEM -> isSystemInDarkTheme(); ThemeMode.LIGHT -> false; ThemeMode.DARK -> true }
|
||||
val context = LocalContext.current
|
||||
// wallpaper colours on Android 12+, like the original; our palette below that
|
||||
val scheme = when {
|
||||
Build.VERSION.SDK_INT >= 31 -> if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
dark -> Dark
|
||||
else -> Light
|
||||
}
|
||||
MaterialTheme(colorScheme = scheme, typography = AppTypography, content = content)
|
||||
}
|
||||
|
||||
/** Fixed palette, for screenshot tests that must not depend on the wallpaper. */
|
||||
@Composable
|
||||
fun StaticClockTheme(dark: Boolean, content: @Composable () -> Unit) =
|
||||
MaterialTheme(colorScheme = if (dark) Dark else Light, typography = AppTypography, content = content)
|
||||
|
||||
/** The big FAB is light with a dark icon in dark mode, tonal in light mode (as in the reference). */
|
||||
@Composable
|
||||
fun fabColors(): Pair<androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color> {
|
||||
val s = MaterialTheme.colorScheme
|
||||
val dark = s.background.run { 0.2126f * red + 0.7152f * green + 0.0722f * blue } < .5f
|
||||
return if (dark) s.primary to s.onPrimary else s.primaryContainer to s.onPrimaryContainer
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Generated from tokens.json by gen_theme.py. Do not edit by hand.
|
||||
package app.uhr.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
object WColor {
|
||||
val bg = Color(0xFFF8F9FF)
|
||||
val surface = Color(0xFFFFFFFF)
|
||||
val card = Color(0xFFEEF0F6)
|
||||
val cardHigh = Color(0xFFE2E5EC)
|
||||
val border = Color(0xFFC3C6CF)
|
||||
val borderInput = Color(0xFF73777F)
|
||||
val text = Color(0xFF191C20)
|
||||
val textMuted = Color(0xFF43474E)
|
||||
val textOff = Color(0xFF5C6068)
|
||||
val accent = Color(0xFF2F5EA8)
|
||||
val onAccent = Color(0xFFFFFFFF)
|
||||
val accentContainer = Color(0xFFD6E3FF)
|
||||
val onAccentContainer = Color(0xFF001B3E)
|
||||
val fab = Color(0xFFA8C7FA)
|
||||
val onFab = Color(0xFF0B2A55)
|
||||
val chipOn = Color(0xFF2F5EA8)
|
||||
val onChipOn = Color(0xFFFFFFFF)
|
||||
val secondaryPill = Color(0xFFDFE2EB)
|
||||
val onSecondaryPill = Color(0xFF191C20)
|
||||
val stop = Color(0xFFFFD8E4)
|
||||
val onStop = Color(0xFF4A1426)
|
||||
val danger = Color(0xFFBA1A1A)
|
||||
val nav = Color(0xFFECEEF4)
|
||||
val navIndicator = Color(0xFFD6E3FF)
|
||||
val darkBg = Color(0xFF111318)
|
||||
val darkSurface = Color(0xFF111318)
|
||||
val darkCard = Color(0xFF1D2024)
|
||||
val darkCardHigh = Color(0xFF272A2F)
|
||||
val darkBorder = Color(0xFF43474E)
|
||||
val darkBorderInput = Color(0xFF8D9199)
|
||||
val darkText = Color(0xFFE2E2E9)
|
||||
val darkTextMuted = Color(0xFFC3C6CF)
|
||||
val darkTextOff = Color(0xFF9A9EA6)
|
||||
val darkAccent = Color(0xFFA8C7FA)
|
||||
val darkOnAccent = Color(0xFF062E6F)
|
||||
val darkAccentContainer = Color(0xFF244A80)
|
||||
val darkOnAccentContainer = Color(0xFFD6E3FF)
|
||||
val darkFab = Color(0xFFA8C7FA)
|
||||
val darkOnFab = Color(0xFF062E6F)
|
||||
val darkChipOn = Color(0xFFA8C7FA)
|
||||
val darkOnChipOn = Color(0xFF062E6F)
|
||||
val darkSecondaryPill = Color(0xFF3A3D43)
|
||||
val darkOnSecondaryPill = Color(0xFFE2E2E9)
|
||||
val darkStop = Color(0xFFF2B8C9)
|
||||
val darkOnStop = Color(0xFF4A1426)
|
||||
val darkDanger = Color(0xFFFFB4AB)
|
||||
val darkNav = Color(0xFF1D2024)
|
||||
val darkNavIndicator = Color(0xFF3A3F47)
|
||||
val ringBg = Color(0xFF1E5A52)
|
||||
val ringOn = Color(0xFFB4F1E6)
|
||||
val ringTrack = Color(0xFF2B6E65)
|
||||
val ringThumb = Color(0xFF3F9A7F)
|
||||
val onRingThumb = Color(0xFFFFFFFF)
|
||||
val timerTrack = Color(0xFF3A3D43)
|
||||
val timerProgress = Color(0xFF7EC4A8)
|
||||
val nightText = Color(0xFFC4433A)
|
||||
val nightBg = Color(0xFF000000)
|
||||
}
|
||||
|
||||
object WType {
|
||||
val labelSm = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.W500)
|
||||
val label = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.W500)
|
||||
val bodySm = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.W400)
|
||||
val body = TextStyle(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.W400)
|
||||
val title = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.W400)
|
||||
val headline = TextStyle(fontSize = 28.sp, lineHeight = 36.sp, fontWeight = FontWeight.W400)
|
||||
val alarmTime = TextStyle(fontSize = 56.sp, lineHeight = 60.sp, fontWeight = FontWeight.W600)
|
||||
val alarmAmpm = TextStyle(fontSize = 20.sp, lineHeight = 24.sp, fontWeight = FontWeight.W600)
|
||||
val timerTime = TextStyle(fontSize = 44.sp, lineHeight = 48.sp, fontWeight = FontWeight.W400)
|
||||
val clockBig = TextStyle(fontSize = 88.sp, lineHeight = 92.sp, fontWeight = FontWeight.W400)
|
||||
val stopwatch = TextStyle(fontSize = 72.sp, lineHeight = 76.sp, fontWeight = FontWeight.W500)
|
||||
val ringing = TextStyle(fontSize = 104.sp, lineHeight = 108.sp, fontWeight = FontWeight.W400)
|
||||
}
|
||||
|
||||
object WSpace {
|
||||
val s0 = 0.dp
|
||||
val s2 = 2.dp
|
||||
val s4 = 4.dp
|
||||
val s8 = 8.dp
|
||||
val s12 = 12.dp
|
||||
val s16 = 16.dp
|
||||
val s20 = 20.dp
|
||||
val s24 = 24.dp
|
||||
val s32 = 32.dp
|
||||
val s48 = 48.dp
|
||||
}
|
||||
|
||||
object WRadius {
|
||||
val xs = 8.dp
|
||||
val sm = 12.dp
|
||||
val md = 16.dp
|
||||
val lg = 24.dp
|
||||
val card = 28.dp
|
||||
val pill = 999.dp
|
||||
val navIndicator = 16.dp
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package app.uhr.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.util.TypedValue
|
||||
import android.widget.RemoteViews
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.AndroidRemoteViews
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import app.uhr.MainActivity
|
||||
import app.uhr.R
|
||||
import app.uhr.data.Graph
|
||||
import app.uhr.ui.components.Fmt
|
||||
import app.uhr.ui.theme.WColor
|
||||
|
||||
private val VERSION = longPreferencesKey("v")
|
||||
|
||||
/** Widget colours: wallpaper on Android 12+, our tokens below. */
|
||||
private data class WPal(val bg: Color, val fg: Color, val accent: Color)
|
||||
|
||||
private fun palette(context: Context): WPal {
|
||||
val dark = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val s = if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
return WPal(s.primaryContainer, s.onPrimaryContainer, s.primary)
|
||||
}
|
||||
return if (dark) WPal(WColor.darkCard, WColor.darkText, WColor.darkAccent) else WPal(WColor.card, WColor.text, WColor.accent)
|
||||
}
|
||||
|
||||
/** Glance's update() only recomposes; bumping state makes widgets read the next alarm again. */
|
||||
suspend fun updateClockWidgets(context: Context) {
|
||||
val manager = GlanceAppWidgetManager(context)
|
||||
for (w in listOf(DigitalClockWidget(), AnalogClockWidget(), NextAlarmWidget())) {
|
||||
manager.getGlanceIds(w.javaClass).forEach { id ->
|
||||
runCatching { updateAppWidgetState(context, id) { it[VERSION] = System.nanoTime() }; w.update(context, id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun nextAlarmText(context: Context): String? {
|
||||
Graph.init(context)
|
||||
val i = Graph.repo.nextInstanceOnce() ?: return null
|
||||
return Fmt.ringDay(context.resources, i.fireAt) + ", " + Fmt.clock(i.fireAt)
|
||||
}
|
||||
|
||||
abstract class ClockWidget : GlanceAppWidget() {
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
Fmt.is24h = android.text.format.DateFormat.is24HourFormat(context)
|
||||
val first = nextAlarmText(context)
|
||||
provideContent {
|
||||
val v = currentState(VERSION) ?: 0L
|
||||
val next by produceState(first, v) { if (v != 0L) value = nextAlarmText(context) }
|
||||
Content(next)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun providePreview(context: Context, widgetCategory: Int) {
|
||||
val next = nextAlarmText(context)
|
||||
provideContent { Content(next) }
|
||||
}
|
||||
|
||||
@Composable abstract fun Content(next: String?)
|
||||
}
|
||||
|
||||
private fun RemoteViews.color(id: Int, c: Color) = setTextColor(id, c.toArgb())
|
||||
|
||||
/** Big live time, date, next alarm. Transparent, like a home-screen clock. */
|
||||
class DigitalClockWidget : ClockWidget() {
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
@Composable
|
||||
override fun Content(next: String?) {
|
||||
val ctx = LocalContext.current
|
||||
val size = LocalSize.current
|
||||
val views = RemoteViews(ctx.packageName, R.layout.widget_digital).apply {
|
||||
setTextViewTextSize(R.id.w_time, TypedValue.COMPLEX_UNIT_SP, (size.height.value * .45f).coerceIn(40f, 110f))
|
||||
if (next != null) setTextViewText(R.id.w_next, "⏰ $next") else setTextViewText(R.id.w_next, "")
|
||||
}
|
||||
Box(GlanceModifier.fillMaxSize().clickable(actionStartActivity(android.content.Intent(ctx, MainActivity::class.java))), contentAlignment = Alignment.Center) {
|
||||
AndroidRemoteViews(views)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Own analog face on a wallpaper-coloured disc. */
|
||||
class AnalogClockWidget : ClockWidget() {
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
@Composable
|
||||
override fun Content(next: String?) {
|
||||
val ctx = LocalContext.current
|
||||
val p = palette(ctx)
|
||||
val views = RemoteViews(ctx.packageName, R.layout.widget_analog)
|
||||
Box(
|
||||
GlanceModifier.fillMaxSize().cornerRadius(999.dp).background(p.bg).clickable(actionStartActivity(android.content.Intent(ctx, MainActivity::class.java))),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { AndroidRemoteViews(views) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Pill with the next alarm. */
|
||||
class NextAlarmWidget : ClockWidget() {
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
@Composable
|
||||
override fun Content(next: String?) {
|
||||
val ctx = LocalContext.current
|
||||
val p = palette(ctx)
|
||||
val views = RemoteViews(ctx.packageName, R.layout.widget_next).apply {
|
||||
setTextViewText(R.id.w_next_text, next ?: ctx.getString(R.string.no_alarm_set))
|
||||
color(R.id.w_next_text, p.fg)
|
||||
setInt(R.id.w_next_icon, "setColorFilter", p.accent.toArgb())
|
||||
}
|
||||
Box(
|
||||
GlanceModifier.fillMaxSize().cornerRadius(999.dp).background(p.bg).clickable(actionStartActivity(android.content.Intent(ctx, MainActivity::class.java))),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { AndroidRemoteViews(views) }
|
||||
}
|
||||
}
|
||||
|
||||
class DigitalClockReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = DigitalClockWidget() }
|
||||
class AnalogClockReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = AnalogClockWidget() }
|
||||
class NextAlarmReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = NextAlarmWidget() }
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="200dp" android:height="200dp" android:viewportWidth="200" android:viewportHeight="200">
|
||||
<path android:fillColor="#40FFFFFF" android:pathData="M100,14m-5,0a5,5 0 1,0 10,0a5,5 0 1,0 -10,0"/>
|
||||
<path android:fillColor="#40FFFFFF" android:pathData="M186,100m-5,0a5,5 0 1,0 10,0a5,5 0 1,0 -10,0"/>
|
||||
<path android:fillColor="#40FFFFFF" android:pathData="M100,186m-5,0a5,5 0 1,0 10,0a5,5 0 1,0 -10,0"/>
|
||||
<path android:fillColor="#40FFFFFF" android:pathData="M14,100m-5,0a5,5 0 1,0 10,0a5,5 0 1,0 -10,0"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="200dp" android:height="200dp" android:viewportWidth="200" android:viewportHeight="200">
|
||||
<path android:strokeColor="#FFA8C7FA" android:strokeWidth="14" android:strokeLineCap="round" android:pathData="M100,100 L100,52"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="200dp" android:height="200dp" android:viewportWidth="200" android:viewportHeight="200">
|
||||
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="8" android:strokeLineCap="round" android:pathData="M100,100 L100,30"/>
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M100,100m-9,0a9,9 0 1,0 18,0a9,9 0 1,0 -18,0"/>
|
||||
</vector>
|
||||