Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08633f918c | ||
|
|
62867fcd07 | ||
|
|
b87683a0b0 | ||
|
|
bc4cd8daf1 | ||
|
|
887299a3a7 | ||
|
|
c163a6d578 | ||
|
|
3c9535e1d5 | ||
|
|
64d664fbe9 | ||
|
|
4b5c9bdf85 | ||
|
|
d1d8d01dd0 |
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
| App | Replaces | Status | Install |
|
| App | Replaces | Status | Install |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
| **Uhr** (`uhr/`) | Google Clock / Pixel Clock | alpha, in daily testing | [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.uhr%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Uhr%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.uhr%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EUhr%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Euhr-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D) · F-Droid: not yet |
|
||||||
| **Wetter** (`wetter/`) | Google Weather / Pixel Weather | alpha, in daily testing | [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.wetter%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Wetter%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.wetter%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EWetter%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Ewetter-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D) · F-Droid: not yet |
|
| **Wetter** (`wetter/`) | Google Weather / Pixel Weather | alpha, in daily testing | [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.wetter%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Wetter%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.wetter%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EWetter%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Ewetter-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D) · F-Droid: not yet |
|
||||||
|
|
||||||
More apps will follow, one folder each. Each one goes through the same pipeline (see [How these apps are made](#how-these-apps-are-made)).
|
More apps will follow, one folder each. Each one goes through the same pipeline (see [How these apps are made](#how-these-apps-are-made)).
|
||||||
@@ -20,9 +21,51 @@ A weather app with the look and feel of Pixel Weather: an animated sky that chan
|
|||||||
- **No account, no tracking, no analytics.** Everything stays on the device. Coordinates are rounded to two decimals (about 1 km) before they reach the weather service.
|
- **No account, no tracking, no analytics.** Everything stays on the device. Coordinates are rounded to two decimals (about 1 km) before they reach the weather service.
|
||||||
- **Open data sources only:** [Open-Meteo](https://open-meteo.com) for forecasts, 15-minute precipitation, air quality, pollen and place search (CC BY 4.0), and [Nominatim / OpenStreetMap](https://nominatim.org) for naming your current location.
|
- **Open data sources only:** [Open-Meteo](https://open-meteo.com) for forecasts, 15-minute precipitation, air quality, pollen and place search (CC BY 4.0), and [Nominatim / OpenStreetMap](https://nominatim.org) for naming your current location.
|
||||||
- **Features today:** current location and saved cities (swipe between them), search with a full preview before saving, precipitation-in-the-next-hours card, a rule-based daily summary, day detail view, units and theme settings, light and dark mode.
|
- **Features today:** current location and saved cities (swipe between them), search with a full preview before saving, precipitation-in-the-next-hours card, a rule-based daily summary, day detail view, units and theme settings, light and dark mode.
|
||||||
- **Five home-screen widgets:** a weather card that grows from 3×2 to 4×5 (hourly panel, then a daily list), a pill, a tilted pill, a one-line bar with a cookie badge, and a classic card. Each one can be customised when added or later: location, system/light/dark, colours (wallpaper Material You, current weather, or five fixed palettes, all WCAG AA checked) and background opacity from 100 % down to fully transparent.
|
- **Six home-screen widgets:** a minimal clock with date and current weather (transparent by default), a weather card that grows from 3×2 to 4×5 (hourly panel, then a daily list), a pill, a tilted pill, a one-line bar with a cookie badge, and a classic card. The widget picker shows real previews. Each one can be customised when added or later: location, system/light/dark, colours (wallpaper Material You, current weather, or five fixed palettes, all WCAG AA checked) and background opacity from 100 % down to fully transparent.
|
||||||
- **Coming next:** severe weather alerts (DWD / MeteoAlarm), precipitation radar map, card reordering, notifications.
|
- **Coming next:** severe weather alerts (DWD / MeteoAlarm), precipitation radar map, card reordering, notifications.
|
||||||
|
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/S01-clear-day.png" width="200" alt="Home, sunny">
|
||||||
|
<img src="docs/screenshots/S01-clear-night-dark.png" width="200" alt="Home, clear night, dark mode">
|
||||||
|
<img src="docs/screenshots/S01-rain-nowcast.png" width="200" alt="Rain with the next-hours precipitation card">
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/S01-full-light.png" width="200" alt="Full forecast with detail tiles">
|
||||||
|
<img src="docs/screenshots/S04-day-dark.png" width="200" alt="Day detail">
|
||||||
|
<img src="docs/screenshots/S08-settings.png" width="200" alt="Settings">
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/widgets.png" width="380" alt="Home-screen widgets: clock, weather card, pill, bar, classic, tilted pill">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<sub>Screenshots are rendered from a real Open-Meteo response (Berlin) by the app's screenshot tests; widgets are rendered on a Pixel with the wallpaper colours of that phone.</sub>
|
||||||
|
|
||||||
|
Requires Android 10 (API 29) or newer.
|
||||||
|
|
||||||
|
### Uhr
|
||||||
|
|
||||||
|
A clock and alarm app with the look and flow of the Pixel clock: alarms in expandable cards, world clock, several timers at once, stopwatch with laps, and a bedtime schedule.
|
||||||
|
|
||||||
|
- **Reliable alarms without Google.** Exact alarms via `AlarmManager.setAlarmClock` (exempt from Doze, shown in the status bar), rescheduled after reboot, time or time zone changes and app updates. Wall-clock based: 7:00 stays 7:00 across daylight saving and travel; the scheduling logic is unit-tested for DST gaps and zone changes.
|
||||||
|
- **Ringing done right:** a full-screen screen over the lock screen with slide to snooze or stop, alarm-stream audio that gets louder gradually, vibration, automatic silence after a set time, and notifications for upcoming, snoozed and missed alarms. Volume keys can snooze or stop.
|
||||||
|
- **No network permission at all.** World clock cities come from the system time zone database (names localised offline), sounds from the device, the app's own tone, or your own audio files.
|
||||||
|
- **Also:** days and labels per alarm, pause until a date, skip the next ring, timers with ring progress and +1:00 that keep running in the background with a live countdown notification, stopwatch with fastest/slowest laps, analog or digital clock, wallpaper colours (Material You), three home-screen widgets (live digital clock, analog clock, next alarm).
|
||||||
|
- **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.
|
Requires Android 10 (API 29) or newer.
|
||||||
|
|
||||||
## Install with Obtainium
|
## Install with Obtainium
|
||||||
@@ -48,6 +91,16 @@ If the button does not open Obtainium (some browsers block links to apps; the pa
|
|||||||
obtainium://app/%7B%22id%22%3A%22app.wetter%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Wetter%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.wetter%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EWetter%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Ewetter-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D
|
obtainium://app/%7B%22id%22%3A%22app.wetter%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Wetter%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.wetter%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EWetter%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Ewetter-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Uhr
|
||||||
|
|
||||||
|
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="54">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.uhr%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Uhr%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.uhr%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EUhr%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Euhr-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D)
|
||||||
|
|
||||||
|
Same setup as Wetter, with the filters `^Uhr ` (release titles) and `^uhr-.*\.apk$` (APKs). Raw link:
|
||||||
|
|
||||||
|
```
|
||||||
|
obtainium://app/%7B%22id%22%3A%22app.uhr%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Uhr%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.uhr%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EUhr%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Euhr-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D
|
||||||
|
```
|
||||||
|
|
||||||
**Releases.** Every app is published as a release titled `<App> <version>` (for example `Wetter 0.1.0`, tag `wetter-v0.1.0`) with one asset named `<app>-<version>.apk`. Obtainium shows *"no matching version"* until the first release of an app exists.
|
**Releases.** Every app is published as a release titled `<App> <version>` (for example `Wetter 0.1.0`, tag `wetter-v0.1.0`) with one asset named `<app>-<version>.apk`. Obtainium shows *"no matching version"* until the first release of an app exists.
|
||||||
|
|
||||||
**F-Droid:** not available yet. The apps contain no proprietary libraries, so an F-Droid build is planned (either the main repository or a self-hosted F-Droid repo).
|
**F-Droid:** not available yet. The apps contain no proprietary libraries, so an F-Droid build is planned (either the main repository or a self-hosted F-Droid repo).
|
||||||
@@ -56,20 +109,22 @@ obtainium://app/%7B%22id%22%3A%22app.wetter%22%2C%22url%22%3A%22https%3A%2F%2Fgi
|
|||||||
|
|
||||||
### The Replica pipeline
|
### The Replica pipeline
|
||||||
|
|
||||||
Every app is rebuilt with **[Replica](https://github.com/Jakeschincariol/replica-skill)**, an open set of eleven Claude Code skills by Jake Schincariol for cloning an app's *functionality and UX patterns* clean-room style. Each skill writes files the next one reads, and everything is kept in [`replica/`](replica/):
|
Every app is rebuilt with **[Replica](https://github.com/Jakeschincariol/replica-skill)**, an open set of eleven Claude Code skills by Jake Schincariol for cloning an app's *functionality and UX patterns* clean-room style. Each skill writes files the next one reads, and everything is kept in [`replica/<app>/`](replica/wetter/):
|
||||||
|
|
||||||
| Step | Skill | What it produced for Wetter |
|
| Step | Skill | What it produced for Wetter |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 1 | `replica-recon` | [Recon map](replica/recon.md) built from public sources (help center, reviews, store listing): 15 screens, 9 user flows, components, inferred data model, [feature matrix](replica/features.csv) |
|
| 1 | `replica-recon` | [Recon map](replica/wetter/recon.md) built from public sources (help center, reviews, store listing): 15 screens, 9 user flows, components, inferred data model, [feature matrix](replica/wetter/features.csv) |
|
||||||
| 2 | `replica-architect` | [Architecture](replica/architecture.md) and [schema](replica/schema.sql): native Kotlin, offline-first, no server, public APIs only |
|
| 2 | `replica-architect` | [Architecture](replica/wetter/architecture.md) and [schema](replica/wetter/schema.sql): native Kotlin, offline-first, no server, public APIs only |
|
||||||
| 3 | `replica-design` | [Design tokens](replica/design/tokens.json) and [component specs](replica/design/components.md), WCAG AA contrast checked (70 colour pairs), generated into Compose code |
|
| 3 | `replica-design` | [Design tokens](replica/wetter/design/tokens.json) and [component specs](replica/wetter/design/components.md), WCAG AA contrast checked (70 colour pairs), generated into Compose code |
|
||||||
| 4 | `replica-build` | The app itself, screen by screen, with a [build log](replica/build-log.md) and [screenshots](replica/clone-screens/) rendered from a real API response |
|
| 4 | `replica-build` | The app itself, screen by screen, with a [build log](replica/wetter/build-log.md) and [screenshots](replica/wetter/clone-screens/) rendered from a real API response |
|
||||||
| next | `replica-test`, `replica-diff`, `replica-entrepreneur`, `replica-brand`, `replica-launch`, `replica-deploy`, `replica-backend` | QA, parity scoring against the original, reading real user reviews for improvements, naming and branding, store listing, release |
|
| next | `replica-test`, `replica-diff`, `replica-entrepreneur`, `replica-brand`, `replica-launch`, `replica-deploy`, `replica-backend` | QA, parity scoring against the original, reading real user reviews for improvements, naming and branding, store listing, release |
|
||||||
|
|
||||||
|
**Uhr** went through the same steps from scratch: [recon](replica/uhr/recon.md) (17 screens, 9 flows), [architecture](replica/uhr/architecture.md), [design tokens](replica/uhr/design/tokens.json) (39 pairs AA), [build log](replica/uhr/build-log.md) and [screenshots](replica/uhr/clone-screens/).
|
||||||
|
|
||||||
The rules Replica enforces, and that this project follows:
|
The rules Replica enforces, and that this project follows:
|
||||||
|
|
||||||
- **Public sources and our own devices only.** No decompiling, no private APIs, no reading the original's code or network traffic.
|
- **Public sources and our own devices only.** No decompiling, no private APIs, no reading the original's code or network traffic.
|
||||||
- **Everything written fresh.** Code, icons, weather glyphs, the animated sky and every label are made from scratch. Reference screenshots of the originals live in `replica/screens/` for layout comparison only and never ship.
|
- **Everything written fresh.** Code, icons, weather glyphs, the animated sky and every label are made from scratch. Reference screenshots of the originals live in `replica/<app>/screens/` for layout comparison only and never ship.
|
||||||
- **What cannot be cloned is left out on purpose:** Google's own forecast models, Gemini summaries, Calendar/Gmail-based insights and account sync. Open alternatives replace them where it makes sense.
|
- **What cannot be cloned is left out on purpose:** Google's own forecast models, Gemini summaries, Calendar/Gmail-based insights and account sync. Open alternatives replace them where it makes sense.
|
||||||
|
|
||||||
### Built with AI, steered by a human
|
### Built with AI, steered by a human
|
||||||
@@ -87,19 +142,20 @@ Each commit says what changed and carries a `Co-Authored-By` line for the AI.
|
|||||||
Requirements: JDK 17 or newer and the Android SDK (platform 36).
|
Requirements: JDK 17 or newer and the Android SDK (platform 36).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd wetter
|
cd wetter # or: cd uhr
|
||||||
./gradlew :app:assembleRelease # APK in app/build/outputs/apk/release/
|
./gradlew :app:assembleRelease # APK in app/build/outputs/apk/release/
|
||||||
./gradlew :app:testDebugUnitTest # unit + screenshot tests
|
./gradlew :app:testDebugUnitTest # unit + screenshot tests
|
||||||
./gradlew :app:recordRoborazziDebug # re-render replica/clone-screens/
|
./gradlew :app:recordRoborazziDebug # re-render replica/wetter/clone-screens/
|
||||||
```
|
```
|
||||||
|
|
||||||
Local release builds are signed with the debug key so they install over each other. Published releases will use a dedicated signing key.
|
Release builds are signed with the project key from `~/.android/nogoogleapps-release.properties` (kept outside the repository); without it they fall back to the debug key for local testing. `scripts/release.sh <app>` builds, signs and publishes a release in the format Obtainium expects.
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
```
|
```
|
||||||
replica/ recon, architecture, design system, build log, screenshots
|
replica/<app>/ recon, architecture, design system, build log, screenshots (one folder per app)
|
||||||
wetter/ the weather app (Kotlin, Jetpack Compose, Glance widgets, Room, WorkManager)
|
wetter/ the weather app (Kotlin, Jetpack Compose, Glance widgets, Room, WorkManager)
|
||||||
|
uhr/ the clock and alarm app (Kotlin, Jetpack Compose, AlarmManager, Glance, Room)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|||||||
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
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 |
|
After Width: | Height: | Size: 87 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: 131 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 127 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: 56 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": {
|
"color": {
|
||||||
"bg": "#f8f9ff",
|
"bg": "#f8f9ff",
|
||||||
"surface": "#ffffff",
|
"surface": "#ffffff",
|
||||||
|
Before Width: | Height: | Size: 499 KiB After Width: | Height: | Size: 499 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 330 KiB After Width: | Height: | Size: 330 KiB |
|
Before Width: | Height: | Size: 866 KiB After Width: | Height: | Size: 866 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build, sign and publish one app as a Gitea release that Obtainium can find.
|
||||||
|
# Usage: scripts/release.sh wetter (version comes from app/build.gradle.kts)
|
||||||
|
# Needs: .giteatoken in the repo root (git-ignored) and ~/.android/nogoogleapps-release.properties
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
app="${1:?app folder, e.g. wetter}"
|
||||||
|
title="$(tr '[:lower:]' '[:upper:]' <<< "${app:0:1}")${app:1}"
|
||||||
|
version="$(sed -nE 's/.*versionName = "([^"]+)".*/\1/p' "$app/app/build.gradle.kts")"
|
||||||
|
tag="$app-v$version"
|
||||||
|
api="https://git.mrblake.cc/api/v1/repos/MrBlake/NoGooleApps"
|
||||||
|
token="$(tr -d ' \n' < .giteatoken)"
|
||||||
|
[ -f ~/.android/nogoogleapps-release.properties ] || { echo "release key missing" >&2; exit 1; }
|
||||||
|
|
||||||
|
(cd "$app" && ./gradlew -q :app:assembleRelease)
|
||||||
|
apk="$app/app/build/outputs/apk/release/app-release.apk"
|
||||||
|
"${ANDROID_HOME:-$HOME/Android/Sdk}"/build-tools/36.0.0/apksigner verify "$apk"
|
||||||
|
|
||||||
|
git -c credential.helper= push -q "https://MrBlake:$token@git.mrblake.cc/MrBlake/NoGooleApps.git" HEAD:main
|
||||||
|
|
||||||
|
body="$(printf '{"tag_name":"%s","target_commitish":"%s","name":"%s %s","prerelease":true}' "$tag" "$(git rev-parse HEAD)" "$title" "$version")"
|
||||||
|
id="$(curl -sf -X POST -H "Authorization: token $token" -H "Content-Type: application/json" -d "$body" "$api/releases" \
|
||||||
|
| python3 -I -c 'import sys,json; print(json.load(sys.stdin)["id"])')"
|
||||||
|
curl -sf -X POST -H "Authorization: token $token" \
|
||||||
|
-F "attachment=@$apk;type=application/vnd.android.package-archive" \
|
||||||
|
"$api/releases/$id/assets?name=$app-$version.apk" > /dev/null
|
||||||
|
echo "published $title $version ($tag)"
|
||||||
@@ -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 = 3
|
||||||
|
versionName = "0.1.2"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,467 @@
|
|||||||
|
package app.uhr.ui.screens
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.media.RingtoneManager
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.core.VisibilityThreshold
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.AlarmOff
|
||||||
|
import androidx.compose.material.icons.outlined.Delete
|
||||||
|
import androidx.compose.material.icons.outlined.Label
|
||||||
|
import androidx.compose.material.icons.outlined.NotificationsActive
|
||||||
|
import androidx.compose.material.icons.outlined.PauseCircle
|
||||||
|
import androidx.compose.material.icons.outlined.Vibration
|
||||||
|
import androidx.compose.material.icons.rounded.Check
|
||||||
|
import androidx.compose.material.icons.rounded.GraphicEq
|
||||||
|
import androidx.compose.material.icons.rounded.ExpandLess
|
||||||
|
import androidx.compose.material.icons.rounded.ExpandMore
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.DatePicker
|
||||||
|
import androidx.compose.material3.DatePickerDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.material3.TimeInput
|
||||||
|
import androidx.compose.material3.TimePicker
|
||||||
|
import androidx.compose.material3.rememberDatePickerState
|
||||||
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.stateDescription
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.uhr.R
|
||||||
|
import app.uhr.data.Alarm
|
||||||
|
import app.uhr.data.AlarmWithInstance
|
||||||
|
import app.uhr.data.InstanceState
|
||||||
|
import app.uhr.data.Settings
|
||||||
|
import app.uhr.ui.components.Fmt
|
||||||
|
import app.uhr.ui.theme.WRadius
|
||||||
|
import app.uhr.ui.theme.WSpace
|
||||||
|
import app.uhr.ui.theme.WType
|
||||||
|
import app.uhr.ui.theme.tabular
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
|
/** One spring for everything that moves when a card opens, so size, content and neighbours stay in step. */
|
||||||
|
private val expandSpring = spring<Float>(dampingRatio = 0.9f, stiffness = 500f)
|
||||||
|
private fun androidx.compose.animation.core.SpringSpec<Float>.toIntOffset() =
|
||||||
|
spring(dampingRatio, stiffness, androidx.compose.ui.unit.IntOffset.VisibilityThreshold)
|
||||||
|
private val expandSpringSize = spring(0.9f, 500f, androidx.compose.ui.unit.IntSize.VisibilityThreshold)
|
||||||
|
|
||||||
|
/** Actions the alarm screen needs; MainActivity passes the repository, tests pass fakes. */
|
||||||
|
interface AlarmActions {
|
||||||
|
fun save(alarm: Alarm)
|
||||||
|
fun delete(alarm: Alarm)
|
||||||
|
fun restore(alarm: Alarm)
|
||||||
|
fun dismissNext(alarm: Alarm)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** S01 + S02: list of alarms, each card expands to edit in place. */
|
||||||
|
@Composable
|
||||||
|
fun AlarmScreen(
|
||||||
|
alarms: List<AlarmWithInstance>,
|
||||||
|
settings: Settings,
|
||||||
|
actions: AlarmActions,
|
||||||
|
snackbar: SnackbarHostState,
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
initiallyExpanded: Long? = null,
|
||||||
|
) {
|
||||||
|
var expanded by rememberSaveable { mutableStateOf(initiallyExpanded) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val res = LocalContext.current.resources
|
||||||
|
if (alarms.isEmpty()) {
|
||||||
|
Column(Modifier.fillMaxSize().padding(contentPadding), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||||
|
Icon(Icons.Outlined.AlarmOff, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Spacer(Modifier.height(WSpace.s12))
|
||||||
|
Text(stringResource(R.string.no_alarms), style = WType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val bedtime = alarms.filter { it.alarm.isBedtimeWake }
|
||||||
|
val others = alarms.filter { !it.alarm.isBedtimeWake }
|
||||||
|
LazyColumn(
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(start = WSpace.s16, end = WSpace.s16, top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding() + 120.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(WSpace.s8),
|
||||||
|
) {
|
||||||
|
if (bedtime.isNotEmpty()) item(key = "h-bed") { SectionHeader(stringResource(R.string.wake_up)) }
|
||||||
|
items(bedtime, key = { it.alarm.id }) { a ->
|
||||||
|
AlarmCard(Modifier.animateItem(placementSpec = expandSpring.toIntOffset()), a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions, onDelete = null)
|
||||||
|
}
|
||||||
|
if (bedtime.isNotEmpty() && others.isNotEmpty()) item(key = "h-other") { SectionHeader(stringResource(R.string.other_alarms)) }
|
||||||
|
items(others, key = { it.alarm.id }) { a ->
|
||||||
|
AlarmCard(
|
||||||
|
Modifier.animateItem(placementSpec = expandSpring.toIntOffset()), a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions,
|
||||||
|
onDelete = {
|
||||||
|
actions.delete(a.alarm)
|
||||||
|
expanded = null
|
||||||
|
scope.launch {
|
||||||
|
val r = snackbar.showSnackbar(res.getString(R.string.alarm_deleted), res.getString(R.string.undo), duration = SnackbarDuration.Short)
|
||||||
|
if (r == SnackbarResult.ActionPerformed) actions.restore(a.alarm)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionHeader(text: String) =
|
||||||
|
Text(text, style = WType.label, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = WSpace.s8, top = WSpace.s12, bottom = WSpace.s4))
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun AlarmCard(
|
||||||
|
modifier: Modifier,
|
||||||
|
item: AlarmWithInstance,
|
||||||
|
settings: Settings,
|
||||||
|
expanded: Boolean,
|
||||||
|
onToggleExpand: () -> Unit,
|
||||||
|
actions: AlarmActions,
|
||||||
|
onDelete: (() -> Unit)?,
|
||||||
|
) {
|
||||||
|
val a = item.alarm
|
||||||
|
val inst = item.instance
|
||||||
|
val res = LocalContext.current.resources
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
var pickTime by remember { mutableStateOf(false) }
|
||||||
|
var pickSound by remember { mutableStateOf(false) }
|
||||||
|
var pickPause by remember { mutableStateOf(false) }
|
||||||
|
val timeColor = if (a.enabled) scheme.onSurface else scheme.onSurfaceVariant.copy(alpha = .7f)
|
||||||
|
val status = when {
|
||||||
|
!a.enabled -> if (a.repeats) Fmt.days(res, a.days, settings.weekStart) else stringResource(R.string.not_scheduled)
|
||||||
|
inst?.state == InstanceState.SNOOZED -> stringResource(R.string.snoozed_until, Fmt.clock(inst.fireAt))
|
||||||
|
a.skipUntil != null && inst != null -> stringResource(R.string.paused_until, Fmt.ringDay(res, inst.fireAt))
|
||||||
|
a.repeats -> Fmt.days(res, a.days, settings.weekStart)
|
||||||
|
inst != null -> Fmt.ringDay(res, inst.fireAt)
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow)
|
||||||
|
.clickable(onClick = onToggleExpand).padding(WSpace.s20)
|
||||||
|
.semantics(mergeDescendants = !expanded) {
|
||||||
|
contentDescription = listOf(a.label, Fmt.time(a.hour, a.minute), status).filter { it.isNotBlank() }.joinToString(", ")
|
||||||
|
stateDescription = res.getString(if (a.enabled) R.string.on else R.string.off)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
val rotation by androidx.compose.animation.core.animateFloatAsState(if (expanded) 180f else 0f, expandSpring, label = "chevron")
|
||||||
|
Row(Modifier.height(48.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
androidx.compose.animation.Crossfade(expanded, Modifier.weight(1f), animationSpec = tween(180), label = "label") { open ->
|
||||||
|
Row(Modifier.fillMaxWidth().height(48.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (open) LabelField(a.label) { actions.save(a.copy(label = it)) }
|
||||||
|
else Text(a.label, style = WType.label, color = scheme.onSurfaceVariant, modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(Modifier.size(28.dp).clip(CircleShape).background(scheme.surfaceContainerHigh), contentAlignment = Alignment.Center) {
|
||||||
|
Icon(Icons.Rounded.ExpandMore, null, Modifier.size(20.dp).graphicsLayer { rotationZ = rotation }, tint = scheme.onSurface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(WSpace.s8))
|
||||||
|
Row(Modifier.clip(RoundedCornerShape(WRadius.sm)).clickable { pickTime = true }, verticalAlignment = Alignment.Bottom) {
|
||||||
|
Text(Fmt.time(a.hour, a.minute), style = tabular(WType.alarmTime), color = timeColor)
|
||||||
|
Fmt.ampm(a.hour)?.let { Text(" $it", style = WType.alarmAmpm, color = timeColor, modifier = Modifier.padding(bottom = 6.dp)) }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(WSpace.s8))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(status, style = WType.bodySm, color = scheme.onSurface, modifier = Modifier.weight(1f))
|
||||||
|
Switch(a.enabled, onCheckedChange = { actions.save(a.copy(enabled = it, skipUntil = null)) })
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
expanded,
|
||||||
|
enter = androidx.compose.animation.expandVertically(expandSpringSize, expandFrom = Alignment.Top) + androidx.compose.animation.fadeIn(tween(220, delayMillis = 60)),
|
||||||
|
exit = androidx.compose.animation.shrinkVertically(expandSpringSize, shrinkTowards = Alignment.Top) + androidx.compose.animation.fadeOut(tween(120)),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Spacer(Modifier.height(WSpace.s12))
|
||||||
|
DayChips(a, settings.weekStart) { actions.save(it) }
|
||||||
|
Spacer(Modifier.height(WSpace.s8))
|
||||||
|
if (a.repeats) OptionRow(Icons.Outlined.PauseCircle, if (a.skipUntil != null) stringResource(R.string.paused_until_date, a.skipUntil.toString()) else stringResource(R.string.pause_alarm)) { pickPause = true }
|
||||||
|
if (inst != null && a.enabled && inst.state == InstanceState.SCHEDULED) OptionRow(Icons.Outlined.AlarmOff, stringResource(R.string.skip_next)) { actions.dismissNext(a) }
|
||||||
|
OptionRow(Icons.Outlined.NotificationsActive, soundName(a.ringtoneUri)) { pickSound = true }
|
||||||
|
OptionRow(Icons.Outlined.Vibration, stringResource(R.string.vibrate), trailing = { if (a.vibrate) Icon(Icons.Rounded.Check, null, tint = scheme.primary) }) {
|
||||||
|
actions.save(a.copy(vibrate = !a.vibrate))
|
||||||
|
}
|
||||||
|
if (onDelete != null) OptionRow(Icons.Outlined.Delete, stringResource(R.string.delete), onClick = onDelete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pickTime) TimeDialog(a.hour, a.minute, onDismiss = { pickTime = false }) { h, m -> pickTime = false; actions.save(a.copy(hour = h, minute = m, enabled = true)) }
|
||||||
|
if (pickSound) SoundDialog(a.ringtoneUri, onDismiss = { pickSound = false }) { uri -> pickSound = false; actions.save(a.copy(ringtoneUri = uri)) }
|
||||||
|
if (pickPause) {
|
||||||
|
val state = rememberDatePickerState(
|
||||||
|
initialSelectedDateMillis = (a.skipUntil ?: LocalDate.now().plusDays(1)).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(),
|
||||||
|
)
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = { pickPause = false },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
pickPause = false
|
||||||
|
state.selectedDateMillis?.let { actions.save(a.copy(skipUntil = Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate(), enabled = true)) }
|
||||||
|
}) { Text(stringResource(android.R.string.ok)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { pickPause = false; actions.save(a.copy(skipUntil = null)) }) { Text(stringResource(R.string.resume)) }
|
||||||
|
},
|
||||||
|
) { DatePicker(state, title = { Text(stringResource(R.string.pause_until), Modifier.padding(start = 24.dp, top = 16.dp)) }) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun androidx.compose.foundation.layout.RowScope.LabelField(value: String, onDone: (String) -> Unit) {
|
||||||
|
var text by remember(value) { mutableStateOf(value) }
|
||||||
|
androidx.compose.foundation.text.BasicTextField(
|
||||||
|
text, { text = it }, Modifier.weight(1f),
|
||||||
|
textStyle = WType.body.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||||
|
cursorBrush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { onDone(text.trim()) }),
|
||||||
|
decorationBox = { inner ->
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Outlined.Label, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Spacer(Modifier.width(WSpace.s12))
|
||||||
|
Box {
|
||||||
|
if (text.isEmpty()) Text(stringResource(R.string.add_label), style = WType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
inner()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { if (text.trim() != value) onDone(text.trim()) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayChips(a: Alarm, weekStart: Int, onChange: (Alarm) -> Unit) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Fmt.orderedDays(weekStart).forEach { d ->
|
||||||
|
val on = a.ringsOn(d)
|
||||||
|
Box(
|
||||||
|
Modifier.size(38.dp).clip(CircleShape)
|
||||||
|
.background(if (on) scheme.primary else androidx.compose.ui.graphics.Color.Transparent)
|
||||||
|
.border(1.dp, if (on) scheme.primary else scheme.outline, CircleShape)
|
||||||
|
.clickable(role = Role.Checkbox) { onChange(a.withDay(d, !on)) }
|
||||||
|
.semantics { contentDescription = Fmt.dayFull(d) },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) { Text(Fmt.dayNarrow(d), style = WType.label, color = if (on) scheme.onPrimary else scheme.onSurface) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OptionRow(icon: ImageVector, text: String, trailing: @Composable (() -> Unit)? = null, onClick: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.sm)).clickable(onClick = onClick).padding(vertical = WSpace.s12, horizontal = WSpace.s4),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Spacer(Modifier.width(WSpace.s16))
|
||||||
|
Text(text, style = WType.bodySm, modifier = Modifier.weight(1f))
|
||||||
|
trailing?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun soundName(uri: String?): String {
|
||||||
|
val context = LocalContext.current
|
||||||
|
return when (uri) {
|
||||||
|
null -> stringResource(R.string.sound_default)
|
||||||
|
Alarm.SILENT -> stringResource(R.string.sound_silent)
|
||||||
|
else -> remember(uri) {
|
||||||
|
runCatching { RingtoneManager.getRingtone(context, Uri.parse(uri))?.getTitle(context) }.getOrNull()
|
||||||
|
?: Uri.parse(uri).lastPathSegment ?: uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S03: M3 time picker with dial/keyboard switch. The Compose dial has no haptics; like the original we tick
|
||||||
|
* on every value the hand passes and give a firmer click when it settles after release.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun TimeDialog(hour: Int, minute: Int, onDismiss: () -> Unit, onPick: (Int, Int) -> Unit) {
|
||||||
|
val state = rememberTimePickerState(hour, minute, Fmt.is24h)
|
||||||
|
var keyboard by remember { mutableStateOf(false) }
|
||||||
|
val view = androidx.compose.ui.platform.LocalView.current
|
||||||
|
androidx.compose.runtime.LaunchedEffect(state) {
|
||||||
|
var last = state.hour to state.minute
|
||||||
|
androidx.compose.runtime.snapshotFlow { state.hour to state.minute }.collect { now ->
|
||||||
|
if (now != last) {
|
||||||
|
view.performHapticFeedback(android.view.HapticFeedbackConstants.CLOCK_TICK)
|
||||||
|
last = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// the dial switches from hours to minutes when the hour settles: confirm that with a firmer click
|
||||||
|
androidx.compose.runtime.LaunchedEffect(state) {
|
||||||
|
var hourMode = state.selection == androidx.compose.material3.TimePickerSelectionMode.Hour
|
||||||
|
androidx.compose.runtime.snapshotFlow { state.selection }.collect { sel ->
|
||||||
|
val nowHour = sel == androidx.compose.material3.TimePickerSelectionMode.Hour
|
||||||
|
if (hourMode && !nowHour) view.performHapticFeedback(android.view.HapticFeedbackConstants.CONFIRM)
|
||||||
|
hourMode = nowHour
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.pick_time)) },
|
||||||
|
text = {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
if (keyboard) TimeInput(state) else TimePicker(state)
|
||||||
|
TextButton(onClick = { keyboard = !keyboard }) { Text(stringResource(if (keyboard) R.string.use_dial else R.string.use_keyboard)) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
view.performHapticFeedback(android.view.HapticFeedbackConstants.CONFIRM)
|
||||||
|
onPick(state.hour, state.minute)
|
||||||
|
}) { Text(stringResource(android.R.string.ok)) }
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** S04: system alarm tones, the bundled one, silence, or an own file (persisted read permission). */
|
||||||
|
@Composable
|
||||||
|
fun SoundDialog(current: String?, onDismiss: () -> Unit, onPick: (String?) -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val tones = remember {
|
||||||
|
val list = mutableListOf<Pair<String, String>>()
|
||||||
|
runCatching {
|
||||||
|
val rm = RingtoneManager(context).apply { setType(RingtoneManager.TYPE_ALARM) }
|
||||||
|
val c = rm.cursor
|
||||||
|
while (c.moveToNext()) list += rm.getRingtoneUri(c.position).toString() to c.getString(RingtoneManager.TITLE_COLUMN_INDEX)
|
||||||
|
}
|
||||||
|
list
|
||||||
|
}
|
||||||
|
// demo on the alarm stream, like the real ring: audible in silent mode and Do Not Disturb
|
||||||
|
val preview = remember { SoundPreview(context) }
|
||||||
|
var playing by remember { mutableStateOf<String?>("none") }
|
||||||
|
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { preview.stop() } }
|
||||||
|
fun play(uri: String?) {
|
||||||
|
if (playing == uri && preview.isPlaying) { preview.stop(); playing = "none"; return }
|
||||||
|
playing = uri
|
||||||
|
preview.play(uri)
|
||||||
|
}
|
||||||
|
val file = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
runCatching { context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) }
|
||||||
|
onPick(uri.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var selected by remember { mutableStateOf(current) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.alarm_sound)) },
|
||||||
|
text = {
|
||||||
|
LazyColumn(Modifier.height(420.dp)) {
|
||||||
|
item { TextButton(onClick = { file.launch(arrayOf("audio/*")) }) { Text(stringResource(R.string.add_own_sound)) } }
|
||||||
|
val rows = listOf<Pair<String?, String>>(null to context.getString(R.string.sound_default), Alarm.SILENT to context.getString(R.string.sound_silent)) + tones
|
||||||
|
items(rows) { (uri, name) ->
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).clickable { selected = uri; play(uri) }.padding(vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
RadioButton(selected == uri, onClick = null)
|
||||||
|
Spacer(Modifier.width(WSpace.s12))
|
||||||
|
Text(name, style = WType.body, modifier = Modifier.weight(1f))
|
||||||
|
if (playing == uri && uri != Alarm.SILENT) Icon(Icons.Rounded.GraphicEq, stringResource(R.string.playing), tint = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = { preview.stop(); onPick(selected) }) { Text(stringResource(android.R.string.ok)) } },
|
||||||
|
dismissButton = { TextButton(onClick = { preview.stop(); onDismiss() }) { Text(stringResource(android.R.string.cancel)) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plays a sound for a few seconds on the alarm stream at the user's alarm volume, then fades out. */
|
||||||
|
class SoundPreview(private val context: android.content.Context) {
|
||||||
|
private var player: android.media.MediaPlayer? = null
|
||||||
|
private val handler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||||
|
val isPlaying: Boolean get() = runCatching { player?.isPlaying == true }.getOrDefault(false)
|
||||||
|
|
||||||
|
fun play(uri: String?) {
|
||||||
|
stop()
|
||||||
|
val u = when (uri) {
|
||||||
|
Alarm.SILENT -> return
|
||||||
|
null -> Uri.parse("android.resource://${context.packageName}/${R.raw.alarm_default}")
|
||||||
|
else -> Uri.parse(uri)
|
||||||
|
}
|
||||||
|
player = runCatching {
|
||||||
|
android.media.MediaPlayer().apply {
|
||||||
|
setAudioAttributes(android.media.AudioAttributes.Builder().setUsage(android.media.AudioAttributes.USAGE_ALARM)
|
||||||
|
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION).build())
|
||||||
|
setDataSource(context, u)
|
||||||
|
isLooping = true
|
||||||
|
prepare()
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
// a demo, not an alarm: stop after a few seconds
|
||||||
|
handler.postDelayed({ stop() }, DEMO_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
player?.runCatching { stop(); release() }
|
||||||
|
player = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object { const val DEMO_MS = 8_000L }
|
||||||
|
}
|
||||||
@@ -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)) } },
|
||||||
|
)
|
||||||
|
}
|
||||||