Author SHA1 Message Date
MrBlakeandClaude Opus 5.5 612c017c2b fix(telefon): ongoing-call notification crashed the InCallService; 0.1.2
CallStyle.forOngoingCall is only allowed with a foreground service or a full-screen intent and
throws otherwise. The crash on every outgoing call made Telecom fall back to the system phone app.
The call path now logs failures instead of crashing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:46:40 +02:00
MrBlakeandClaude Opus 5.5 a332ca9c23 fix(telefon): ask for the phone role on first start and before calling without it; 0.1.1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:41:47 +02:00
MrBlakeandClaude Opus 5.5 830e653690 build(telefon): default phone app with InCallService, call log, favourites, keypad, details
uhr: handle the public AlarmClock intents (set/show alarm and timer, dismiss, snooze); 0.2.1
docs: how each app replaces the system app

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:30:12 +02:00
MrBlakeandClaude Opus 5.5 0f709aaff8 replica(telefon): architecture, tokens (20 pairs AA), components
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:21:50 +02:00
MrBlakeandClaude Opus 5.5 43030575c4 replica(telefon): recon, feature matrix, reference screenshots
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:21:18 +02:00
MrBlakeandClaude Opus 5.5 5027fbcd85 build(rechner): exact expression engine, keypad, scientific panel, history, memory; README
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:02:11 +02:00
MrBlakeandClaude Opus 5.5 7ef64324fb replica(rechner): design tokens (24 pairs AA) and component specs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:51:57 +02:00
MrBlakeandClaude Opus 5.5 021f7106cc replica(rechner): architecture and schema
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:51:27 +02:00
MrBlakeandClaude Opus 5.5 f25e10d217 replica(rechner): recon, feature matrix, reference screenshots
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:50:45 +02:00
MrBlakeandClaude Opus 5.5 7812f62c0f uhr: sleep sounds, sunrise alarm, reorder world clock, rename timers; 0.2.0
- own generated sleep sounds (rain, waves, brown noise) with sleep timer and fade-out
- sunrise: 15 min before the bedtime wake alarm, a full-screen screen brightens from ember to daylight
- world clock: long-press drag to reorder, with haptic ticks; order persists
- timers: tap the title to rename

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:22:32 +02:00
MrBlakeandClaude Opus 5.5 b87683a0b0 docs: Uhr in README with screenshots and Obtainium link
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:17:23 +02:00
MrBlakeandClaude Opus 5.5 bc4cd8daf1 build(uhr): alarms, ringing, timer, stopwatch, world clock, bedtime, settings, widgets
- exact alarms via setAlarmClock, rescheduled on boot/time/zone change and app update
- foreground ringing service: alarm-stream audio with gradual volume, vibration,
  full-screen screen with slide to snooze/stop, silence-after, missed notification
- timers and stopwatch keep timestamps only; live countdown/chronometer notifications
- world clock offline from the time zone database (no network permission at all)
- own alarm tone, own analog face and launcher icon
- next-ring logic unit-tested incl. DST gaps and zone changes; screenshot tests

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:16:50 +02:00
MrBlakeandClaude Opus 5.5 887299a3a7 replica(uhr): design tokens and component specs (39 pairs AA)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:59:51 +02:00
MrBlakeandClaude Opus 5.5 c163a6d578 replica(uhr): architecture and schema
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:58:56 +02:00
MrBlakeandClaude Opus 5.5 3c9535e1d5 replica(uhr): recon map, feature matrix, reference screenshots
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:58:04 +02:00
MrBlakeandClaude Opus 5.5 64d664fbe9 replica: one folder per app (replica/wetter)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:56:22 +02:00
233 changed files with 10721 additions and 12 deletions
+124 -9
View File
@@ -8,6 +8,9 @@
| App | Replaces | Status | Install | | App | Replaces | Status | Install |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| **Telefon** (`telefon/`) | Google Phone | alpha, not yet tested with real calls | [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.telefon%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Telefon%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.telefon%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5ETelefon%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Etelefon-.%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 |
| **Rechner** (`rechner/`) | Google Calculator | alpha | [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.rechner%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Rechner%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.rechner%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5ERechner%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Erechner-.%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 |
| **Uhr** (`uhr/`) | Google Clock / Pixel Clock | alpha, in daily testing | [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.uhr%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Uhr%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.uhr%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EUhr%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Euhr-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D) · F-Droid: not yet |
| **Wetter** (`wetter/`) | Google Weather / Pixel Weather | alpha, in daily testing | [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.wetter%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Wetter%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.wetter%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EWetter%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Ewetter-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D) · F-Droid: not yet | | **Wetter** (`wetter/`) | Google Weather / Pixel Weather | alpha, in daily testing | [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.wetter%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Wetter%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.wetter%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EWetter%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Ewetter-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D) · F-Droid: not yet |
More apps will follow, one folder each. Each one goes through the same pipeline (see [How these apps are made](#how-these-apps-are-made)). More apps will follow, one folder each. Each one goes through the same pipeline (see [How these apps are made](#how-these-apps-are-made)).
@@ -42,6 +45,79 @@ A weather app with the look and feel of Pixel Weather: an animated sky that chan
Requires Android 10 (API 29) or newer. Requires Android 10 (API 29) or newer.
### Telefon
A phone app in the style of the Pixel dialer, made to replace the system phone app.
- **Becomes your default phone app** (Android's dialer role): shows incoming calls full screen over the lock screen with slide to answer or decline, the in-call screen with mute, speaker, Bluetooth, hold and keypad, and call notifications. The ringtone is played by Android itself.
- **Start:** search, filters (all, missed, contacts), favourites from your starred contacts, call history grouped by day with missed calls in red; repeated calls fold into one row.
- **Keypad** with T9 contact suggestions, **voicemail** dials your SIM's mailbox number, **details** per contact or number with history, SMS, favourite toggle and "add contact".
- **Your data stays in the system.** Call history and contacts are read from Android's own databases, nothing is copied, and there is no network permission. Declining with a quick reply hands the text to your SMS app.
- Not included: spam detection, call screening, recording and live translation (Google services).
<p align="center">
<img src="docs/screenshots/telefon/S01-home-light.png" width="200" alt="Start with favourites and call history">
<img src="docs/screenshots/telefon/S06-incoming-light.png" width="200" alt="Incoming call">
<img src="docs/screenshots/telefon/S07-incall-dark.png" width="200" alt="In call">
</p>
<sub>Screenshots use made-up contacts.</sub>
### Rechner
A calculator with the flow of the Pixel calculator: live result while typing, a scientific row that expands into a full panel, and the history in a sheet you pull down from the display.
- **Exact arithmetic.** Numbers are decimal, not floating point: 0,1 + 0,2 is 0,3, 1 ÷ 3 × 3 is 1, 100! is exact. Trigonometry snaps exact angles (sin 30° = 0,5, cos π = −1). The engine is covered by unit tests.
- **Smart input:** one key for parentheses that opens or closes as needed, operators replace each other, implicit multiplication (2π, 3(4)), percent the way people expect (50 + 10 % = 55), unfinished input still shows a preview.
- **Scientific:** √ π ^ ! · Deg/Rad · sin cos tan and their inverses (Inv) · e ln log · eˣ 10ˣ x².
- **History** grouped by day, tap to reuse; copy, paste and a memory (MS/MR) from a long press on the display; wallpaper colours, light and dark.
- **No permissions** except vibration for key feedback. No network.
<p align="center">
<img src="docs/screenshots/rechner/S01-typing-light.png" width="200" alt="Typing with live result">
<img src="docs/screenshots/rechner/S02-scientific-dark.png" width="200" alt="Scientific panel">
<img src="docs/screenshots/rechner/S03-history-dark.png" width="200" alt="History">
</p>
Requires Android 10 (API 29) or newer.
### Uhr
A clock and alarm app with the look and flow of the Pixel clock: alarms in expandable cards, world clock, several timers at once, stopwatch with laps, and a bedtime schedule.
- **Reliable alarms without Google.** Exact alarms via `AlarmManager.setAlarmClock` (exempt from Doze, shown in the status bar), rescheduled after reboot, time or time zone changes and app updates. Wall-clock based: 7:00 stays 7:00 across daylight saving and travel; the scheduling logic is unit-tested for DST gaps and zone changes.
- **Ringing done right:** a full-screen screen over the lock screen with slide to snooze or stop, alarm-stream audio that gets louder gradually, vibration, automatic silence after a set time, and notifications for upcoming, snoozed and missed alarms. Volume keys can snooze or stop.
- **No network permission at all.** World clock cities come from the system time zone database (names localised offline), sounds from the device, the app's own tone, or your own audio files.
- **Also:** days and labels per alarm, pause until a date, skip the next ring, timers with ring progress and +1:00 that keep running in the background with a live countdown notification, stopwatch with fastest/slowest laps, analog or digital clock, wallpaper colours (Material You), three home-screen widgets (live digital clock, analog clock, next alarm).
- **Bedtime extras:** own sleep sounds (rain, waves, deep noise) with a sleep timer that fades out, and a sunrise option that slowly brightens the screen 15 minutes before the wake-up alarm.
- **Coming next:** widget style options like Wetter, screensaver/night clock.
<p align="center">
<img src="docs/screenshots/uhr/S01-alarms-dark.png" width="200" alt="Alarm list">
<img src="docs/screenshots/uhr/S02-expanded-dark.png" width="200" alt="Alarm expanded">
<img src="docs/screenshots/uhr/S11-ringing.png" width="200" alt="Ringing screen with slide to snooze or stop">
</p>
<p align="center">
<img src="docs/screenshots/uhr/S06-clock-dark.png" width="200" alt="World clock">
<img src="docs/screenshots/uhr/S08-timers-dark.png" width="200" alt="Timers">
<img src="docs/screenshots/uhr/S09-stopwatch-dark.png" width="200" alt="Stopwatch with laps">
</p>
<sub>Screenshots rendered by the app's screenshot tests with the fallback palette; on Android 12+ the app uses your wallpaper colours.</sub>
Requires Android 10 (API 29) or newer.
## Replacing the system apps
Each app plugs into the places Android uses to pick an app, so you can uninstall or disable the Google version (on GrapheneOS most are not installed anyway):
| App | How it takes over |
| --- | --- |
| Telefon | Android's default phone app role (Settings → Apps → Default apps → Phone app), plus `tel:` links and the dial intent |
| Uhr | the public alarm clock intents: set alarm, set timer, show alarms/timers, dismiss and snooze, used by voice assistants, calendars and other apps; exact alarms shown in the status bar and lock screen |
| Rechner | the system calculator category, so the calculator key of keyboards and launchers opens it |
| Wetter | no system role exists for weather; its widgets and the "at a glance"-style clock widget replace the Google ones |
## Install with Obtainium ## Install with Obtainium
[Obtainium](https://github.com/ImranR98/Obtainium) installs and updates apps straight from this repository's releases, with no store in between. [Obtainium](https://github.com/ImranR98/Obtainium) installs and updates apps straight from this repository's releases, with no store in between.
@@ -65,6 +141,36 @@ 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
```
### Rechner
[<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.rechner%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Rechner%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.rechner%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5ERechner%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Erechner-.%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)
Filters: `^Rechner ` (release titles) and `^rechner-.*\.apk$` (APKs). Raw link:
```
obtainium://app/%7B%22id%22%3A%22app.rechner%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Rechner%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.rechner%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5ERechner%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Erechner-.%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
```
### Telefon
[<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.telefon%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Telefon%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.telefon%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5ETelefon%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Etelefon-.%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)
Filters: `^Telefon ` and `^telefon-.*\.apk$`. Raw link:
```
obtainium://app/%7B%22id%22%3A%22app.telefon%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Telefon%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.telefon%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5ETelefon%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Etelefon-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D
```
**Releases.** Every app is published as a release titled `<App> <version>` (for example `Wetter 0.1.0`, tag `wetter-v0.1.0`) with one asset named `<app>-<version>.apk`. Obtainium shows *"no matching version"* until the first release of an app exists. **Releases.** Every app is published as a release titled `<App> <version>` (for example `Wetter 0.1.0`, tag `wetter-v0.1.0`) with one asset named `<app>-<version>.apk`. Obtainium shows *"no matching version"* until the first release of an app exists.
**F-Droid:** not available yet. The apps contain no proprietary libraries, so an F-Droid build is planned (either the main repository or a self-hosted F-Droid repo). **F-Droid:** not available yet. The apps contain no proprietary libraries, so an F-Droid build is planned (either the main repository or a self-hosted F-Droid repo).
@@ -73,20 +179,26 @@ 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 |
**Telefon** as well: [recon](replica/telefon/recon.md), [architecture](replica/telefon/architecture.md), [tokens](replica/telefon/design/tokens.json) (20 pairs AA), [build log](replica/telefon/build-log.md).
**Rechner** too: [recon](replica/rechner/recon.md), [architecture](replica/rechner/architecture.md), [tokens](replica/rechner/design/tokens.json) (24 pairs AA), [build log](replica/rechner/build-log.md), [screenshots](replica/rechner/clone-screens/).
**Uhr** went through the same steps from scratch: [recon](replica/uhr/recon.md) (17 screens, 9 flows), [architecture](replica/uhr/architecture.md), [design tokens](replica/uhr/design/tokens.json) (39 pairs AA), [build log](replica/uhr/build-log.md) and [screenshots](replica/uhr/clone-screens/).
The rules Replica enforces, and that this project follows: The rules Replica enforces, and that this project follows:
- **Public sources and our own devices only.** No decompiling, no private APIs, no reading the original's code or network traffic. - **Public sources and our own devices only.** No decompiling, no private APIs, no reading the original's code or network traffic.
- **Everything written fresh.** Code, icons, weather glyphs, the animated sky and every label are made from scratch. Reference screenshots of the originals live in `replica/screens/` for layout comparison only and never ship. - **Everything written fresh.** Code, icons, weather glyphs, the animated sky and every label are made from scratch. Reference screenshots of the originals live in `replica/<app>/screens/` for layout comparison only and never ship.
- **What cannot be cloned is left out on purpose:** Google's own forecast models, Gemini summaries, Calendar/Gmail-based insights and account sync. Open alternatives replace them where it makes sense. - **What cannot be cloned is left out on purpose:** Google's own forecast models, Gemini summaries, Calendar/Gmail-based insights and account sync. Open alternatives replace them where it makes sense.
### Built with AI, steered by a human ### Built with AI, steered by a human
@@ -104,10 +216,10 @@ Each commit says what changed and carries a `Co-Authored-By` line for the AI.
Requirements: JDK 17 or newer and the Android SDK (platform 36). Requirements: JDK 17 or newer and the Android SDK (platform 36).
```bash ```bash
cd wetter cd wetter # or: cd uhr, cd rechner
./gradlew :app:assembleRelease # APK in app/build/outputs/apk/release/ ./gradlew :app:assembleRelease # APK in app/build/outputs/apk/release/
./gradlew :app:testDebugUnitTest # unit + screenshot tests ./gradlew :app:testDebugUnitTest # unit + screenshot tests
./gradlew :app:recordRoborazziDebug # re-render replica/clone-screens/ ./gradlew :app:recordRoborazziDebug # re-render replica/wetter/clone-screens/
``` ```
Release builds are signed with the project key from `~/.android/nogoogleapps-release.properties` (kept outside the repository); without it they fall back to the debug key for local testing. `scripts/release.sh <app>` builds, signs and publishes a release in the format Obtainium expects. Release builds are signed with the project key from `~/.android/nogoogleapps-release.properties` (kept outside the repository); without it they fall back to the debug key for local testing. `scripts/release.sh <app>` builds, signs and publishes a release in the format Obtainium expects.
@@ -115,8 +227,11 @@ Release builds are signed with the project key from `~/.android/nogoogleapps-rel
## Repository layout ## Repository layout
``` ```
replica/ recon, architecture, design system, build log, screenshots replica/<app>/ recon, architecture, design system, build log, screenshots (one folder per app)
wetter/ the weather app (Kotlin, Jetpack Compose, Glance widgets, Room, WorkManager) wetter/ the weather app (Kotlin, Jetpack Compose, Glance widgets, Room, WorkManager)
telefon/ the phone app (Kotlin, Jetpack Compose, InCallService, system call log and contacts)
rechner/ the calculator (Kotlin, Jetpack Compose, own exact expression engine)
uhr/ the clock and alarm app (Kotlin, Jetpack Compose, AlarmManager, Glance, Room)
``` ```
## Credits ## Credits
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+6
View File
@@ -0,0 +1,6 @@
.gradle/
build/
local.properties
.idea/
*.iml
.kotlin/
+93
View File
@@ -0,0 +1,93 @@
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.rechner"
compileSdk = 36
defaultConfig {
applicationId = "app.rechner"
minSdk = 29
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
// Release key lives outside the repo; without it, release builds fall back to the debug key (local only).
val keyProps = Properties().apply {
val f = file(System.getProperty("user.home") + "/.android/nogoogleapps-release.properties")
if (f.exists()) f.inputStream().use { load(it) }
}
signingConfigs {
if (keyProps.isNotEmpty()) create("release") {
storeFile = file(keyProps.getProperty("storeFile"))
storePassword = keyProps.getProperty("storePassword")
keyAlias = keyProps.getProperty("keyAlias")
keyPassword = keyProps.getProperty("keyPassword")
}
}
buildTypes {
debug {
// installs next to the release app from Obtainium
applicationIdSuffix = ".debug"
resValue("string", "app_name", "Rechner (Debug)")
}
release {
resValue("string", "app_name", "Rechner")
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.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("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/rechner/clone-screens")) }
View File
@@ -0,0 +1,60 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "68577f81880de5c04e4721ad8ba32e4e",
"entities": [
{
"tableName": "history",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `expression` TEXT NOT NULL, `result` TEXT NOT NULL, `created_at` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "expression",
"columnName": "expression",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "result",
"columnName": "result",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_history_created_at",
"unique": false,
"columnNames": [
"created_at"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_history_created_at` ON `${TABLE_NAME}` (`created_at`)"
}
]
}
],
"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, '68577f81880de5c04e4721ad8ba32e4e')"
]
}
}
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- no INTERNET: a calculator has nothing to send anywhere -->
<uses-permission android:name="android.permission.VIBRATE" />
<application
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:windowSoftInputMode="stateAlwaysHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.APP_CALCULATOR" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,26 @@
package app.rechner
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import app.rechner.data.Graph
import app.rechner.data.Prefs
import app.rechner.ui.CalculatorScreen
import app.rechner.ui.CalculatorViewModel
import app.rechner.ui.theme.CalcTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
Graph.init(this)
val vm = CalculatorViewModel.get(this)
setContent {
val prefs by Graph.store.prefs.collectAsState(initial = Prefs())
CalcTheme(prefs.theme) { CalculatorScreen(vm, prefs) }
}
}
}
@@ -0,0 +1,79 @@
package app.rechner.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Index
import androidx.room.Insert
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import app.rechner.engine.Angle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@Entity(tableName = "history", indices = [Index("created_at")])
data class HistoryEntry(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val expression: String,
val result: String,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
)
@Dao
interface HistoryDao {
@Query("SELECT * FROM history ORDER BY created_at") fun all(): Flow<List<HistoryEntry>>
@Insert suspend fun insert(e: HistoryEntry)
@Query("DELETE FROM history") suspend fun clear()
/** keep the list bounded */
@Query("DELETE FROM history WHERE id NOT IN (SELECT id FROM history ORDER BY created_at DESC LIMIT 500)") suspend fun trim()
}
@Database(entities = [HistoryEntry::class], version = 1, exportSchema = true)
abstract class CalcDb : RoomDatabase() {
abstract fun history(): HistoryDao
}
enum class ThemeMode { SYSTEM, LIGHT, DARK }
data class Prefs(val angle: Angle = Angle.DEG, val memory: String? = null, val expression: String = "", val theme: ThemeMode = ThemeMode.SYSTEM)
private val Context.store by preferencesDataStore("calc")
class Store(private val context: Context) {
private val kAngle = stringPreferencesKey("angle")
private val kMemory = stringPreferencesKey("memory")
private val kExpr = stringPreferencesKey("expression")
private val kTheme = stringPreferencesKey("theme")
val prefs: Flow<Prefs> = context.store.data.map { p ->
Prefs(
angle = p[kAngle]?.let { runCatching { Angle.valueOf(it) }.getOrNull() } ?: Angle.DEG,
memory = p[kMemory],
expression = p[kExpr] ?: "",
theme = p[kTheme]?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() } ?: ThemeMode.SYSTEM,
)
}
suspend fun setAngle(a: Angle) = context.store.edit { it[kAngle] = a.name }
suspend fun setMemory(v: String?) = context.store.edit { if (v == null) it.remove(kMemory) else it[kMemory] = v }
suspend fun setExpression(v: String) = context.store.edit { it[kExpr] = v }
suspend fun setTheme(t: ThemeMode) = context.store.edit { it[kTheme] = t.name }
}
object Graph {
lateinit var history: HistoryDao private set
lateinit var store: Store private set
fun init(context: Context) {
if (::store.isInitialized) return
val app = context.applicationContext
history = Room.databaseBuilder(app, CalcDb::class.java, "calc.db").build().history()
store = Store(app)
}
}
@@ -0,0 +1,51 @@
package app.rechner.engine
/**
* Keypad editing on the expression text. Operators replace a trailing operator, the
* parenthesis key opens or closes depending on context, backspace removes whole tokens.
*/
object Editor {
private val binary = setOf('+', '−', '×', '÷', '^')
private val functionTokens = listOf("sin⁻¹(", "cos⁻¹(", "tan⁻¹(", "sin(", "cos(", "tan(", "ln(", "log(")
fun input(expr: String, key: String, decimal: Char): String {
val last = expr.lastOrNull()
return when {
key.length == 1 && key[0].isDigit() -> expr + key
key == decimal.toString() -> {
val number = expr.takeLastWhile { it.isDigit() || it == decimal || it == '.' }
when {
decimal in number -> expr
number.isEmpty() -> expr + "0" + decimal
else -> expr + decimal
}
}
key.length == 1 && key[0] in binary -> {
val op = key[0]
when {
expr.isEmpty() -> if (op == '−') "−" else expr
last != null && last in binary -> {
// "×−" is allowed for a negative factor, otherwise replace the operator
if (op == '−' && last != '−' && last != '+') expr + op else expr.dropLast(1) + op
}
last == '(' -> if (op == '−') expr + op else expr
else -> expr + op
}
}
key == "()" -> {
val open = expr.count { it == '(' } - expr.count { it == ')' }
val closeable = open > 0 && last != null && last != '(' && last !in binary
if (closeable) "$expr)" else "$expr("
}
key == "%" || key == "!" -> if (last != null && (last.isDigit() || last == ')' || last == 'π' || last == 'e' || last == '!' || last == '%')) expr + key else expr
key == "√" || key == "π" || key == "e" -> expr + key
key.endsWith("(") -> expr + key
else -> expr + key
}
}
fun backspace(expr: String): String {
functionTokens.firstOrNull { expr.endsWith(it) }?.let { return expr.dropLast(it.length) }
return expr.dropLast(1)
}
}
@@ -0,0 +1,297 @@
package app.rechner.engine
import java.math.BigDecimal
import java.math.BigInteger
import java.math.MathContext
import java.math.RoundingMode
import kotlin.math.PI
enum class Angle { DEG, RAD }
sealed interface Outcome {
data class Value(val value: BigDecimal) : Outcome
data class Error(val kind: ErrorKind) : Outcome
/** Nothing to show yet (empty or only an operator). */
data object Empty : Outcome
}
enum class ErrorKind { DIV_ZERO, DOMAIN, TOO_BIG, SYNTAX }
class CalcException(val kind: ErrorKind) : Exception(kind.name)
/**
* Parser and evaluator for the expressions the keypad produces.
* Display symbols: digits, decimal separator, + − × ÷ ^ ! % √ π e ( ), and the functions
* sin cos tan sin⁻¹ cos⁻¹ tan⁻¹ ln log. Unfinished input is tolerated: open parentheses are
* closed and a trailing operator is ignored, so the live preview always has something to show.
*/
class Engine(private val decimalSeparator: Char = ',') {
private val mc = MathContext(50, RoundingMode.HALF_EVEN)
fun evaluate(expression: String, angle: Angle = Angle.DEG, lenient: Boolean = true): Outcome {
var tokens = runCatching { tokenize(expression) }.getOrElse { return Outcome.Error(ErrorKind.SYNTAX) }
if (lenient) while (tokens.isNotEmpty() && tokens.last().let { it is Tok.Op || it is Tok.Func || it == Tok.Sqrt || it == Tok.Open }) tokens = tokens.dropLast(1)
if (tokens.isEmpty()) return Outcome.Empty
val open = tokens.count { it == Tok.Open } - tokens.count { it == Tok.Close }
if (open > 0) tokens = tokens + List(open) { Tok.Close }
return try {
val p = Parser(tokens, angle)
val v = p.parseExpression()
if (!p.atEnd()) throw CalcException(ErrorKind.SYNTAX)
if (v.abs() > LIMIT) throw CalcException(ErrorKind.TOO_BIG)
Outcome.Value(v)
} catch (e: CalcException) {
Outcome.Error(e.kind)
} catch (e: ArithmeticException) {
Outcome.Error(ErrorKind.DOMAIN)
}
}
// --- tokens --------------------------------------------------------------------------------
sealed interface Tok {
data class Num(val v: BigDecimal) : Tok
data class Op(val c: Char) : Tok
data class Func(val name: String) : Tok
data object Open : Tok
data object Close : Tok
data object Fact : Tok
data object Percent : Tok
data object Sqrt : Tok
data object Pi : Tok
data object E : Tok
}
private val functions = listOf("sin⁻¹", "cos⁻¹", "tan⁻¹", "sin", "cos", "tan", "ln", "log")
fun tokenize(s: String): List<Tok> {
val out = mutableListOf<Tok>()
var i = 0
while (i < s.length) {
val c = s[i]
when {
c.isWhitespace() || c == '.' && decimalSeparator == ',' -> i++ // grouping dots are cosmetic
c.isDigit() || c == decimalSeparator -> {
val start = i
while (i < s.length && (s[i].isDigit() || s[i] == decimalSeparator || (s[i] == '.' && decimalSeparator == ','))) i++
val raw = s.substring(start, i).filter { it != '.' || decimalSeparator == '.' }.replace(decimalSeparator, '.')
if (raw.count { it == '.' } > 1) throw CalcException(ErrorKind.SYNTAX)
// "E" exponent from pasted scientific results
var text = if (raw == ".") "0" else raw
if (i < s.length && s[i] == 'E' && i + 1 < s.length && (s[i + 1].isDigit() || s[i + 1] == '−' || s[i + 1] == '-')) {
val e0 = i + 1
i += 2
while (i < s.length && s[i].isDigit()) i++
text += "E" + s.substring(e0, i).replace('−', '-')
}
out += Tok.Num(BigDecimal(text))
}
c == '+' -> { out += Tok.Op('+'); i++ }
c == '−' || c == '-' -> { out += Tok.Op('−'); i++ }
c == '×' || c == '*' -> { out += Tok.Op('×'); i++ }
c == '÷' || c == '/' -> { out += Tok.Op('÷'); i++ }
c == '^' -> { out += Tok.Op('^'); i++ }
c == '(' -> { out += Tok.Open; i++ }
c == ')' -> { out += Tok.Close; i++ }
c == '!' -> { out += Tok.Fact; i++ }
c == '%' -> { out += Tok.Percent; i++ }
c == '√' -> { out += Tok.Sqrt; i++ }
c == 'π' -> { out += Tok.Pi; i++ }
c == 'e' -> { out += Tok.E; i++ }
else -> {
val f = functions.firstOrNull { s.startsWith(it, i) } ?: throw CalcException(ErrorKind.SYNTAX)
out += Tok.Func(f); i += f.length
if (i < s.length && s[i] == '(') { out += Tok.Open; i++ } else out += Tok.Open
}
}
}
return out
}
// --- parser --------------------------------------------------------------------------------
private inner class Parser(private val t: List<Tok>, private val angle: Angle) {
private var p = 0
fun atEnd() = p >= t.size
private fun peek() = t.getOrNull(p)
private fun next() = t[p++]
/** additive: a + b% means a + a·b/100 */
fun parseExpression(): BigDecimal {
var v = parseTerm()
while (true) {
val op = (peek() as? Tok.Op)?.c ?: break
if (op != '+' && op != '−') break
next()
val (rhs, wasPercent) = parseTermWithPercent()
val r = if (wasPercent) v.multiply(rhs, mc) else rhs
v = if (op == '+') v.add(r, mc) else v.subtract(r, mc)
}
return v
}
private fun parseTermWithPercent(): Pair<BigDecimal, Boolean> {
val start = p
val v = parseTerm()
// a term that is just "x%" counts as a percentage of the left side
val plainPercent = p - start >= 2 && t[p - 1] == Tok.Percent && t.subList(start, p - 1).none { it is Tok.Op || it == Tok.Percent }
return v to plainPercent
}
private fun startsPrimary(k: Tok?) = k is Tok.Num || k == Tok.Open || k is Tok.Func || k == Tok.Sqrt || k == Tok.Pi || k == Tok.E
private fun parseTerm(): BigDecimal {
var v = parseUnary()
while (true) {
val k = peek()
v = when {
k is Tok.Op && k.c == '×' -> { next(); v.multiply(parseUnary(), mc) }
k is Tok.Op && k.c == '÷' -> {
next()
val d = parseUnary()
if (d.signum() == 0) throw CalcException(ErrorKind.DIV_ZERO)
v.divide(d, mc)
}
startsPrimary(k) -> v.multiply(parseUnary(), mc) // implicit: 2π, 3(4), 2√9
else -> return v
}
}
}
private fun parseUnary(): BigDecimal {
val k = peek()
if (k is Tok.Op && k.c == '−') { next(); return parseUnary().negate() }
if (k is Tok.Op && k.c == '+') { next(); return parseUnary() }
return parsePower()
}
/** right associative, binds tighter than unary minus on the left: −2^2 = −4 */
private fun parsePower(): BigDecimal {
val base = parsePostfix()
val k = peek()
if (k is Tok.Op && k.c == '^') {
next()
return pow(base, parseUnary())
}
return base
}
private fun parsePostfix(): BigDecimal {
var v = parsePrimary()
while (true) {
v = when (peek()) {
Tok.Fact -> { next(); factorial(v) }
Tok.Percent -> { next(); v.divide(BigDecimal(100), mc) }
else -> return v
}
}
}
private fun parsePrimary(): BigDecimal {
return when (val k = next()) {
is Tok.Num -> k.v
Tok.Pi -> PI_BIG
Tok.E -> E_BIG
Tok.Open -> {
if (peek() == Tok.Close) throw CalcException(ErrorKind.SYNTAX)
val v = parseExpression()
if (peek() != Tok.Close) throw CalcException(ErrorKind.SYNTAX)
next(); v
}
Tok.Sqrt -> {
val x = parsePostfix()
if (x.signum() < 0) throw CalcException(ErrorKind.DOMAIN)
x.sqrt(mc)
}
is Tok.Func -> {
if (peek() != Tok.Open) throw CalcException(ErrorKind.SYNTAX)
next()
val x = parseExpression()
if (peek() != Tok.Close) throw CalcException(ErrorKind.SYNTAX)
next()
applyFunction(k.name, x)
}
else -> throw CalcException(ErrorKind.SYNTAX)
}
}
private fun toRad(x: Double) = if (angle == Angle.DEG) x * PI / 180 else x
private fun fromRad(x: Double) = if (angle == Angle.DEG) x * 180 / PI else x
private fun applyFunction(name: String, x: BigDecimal): BigDecimal {
val d = x.toDouble()
val r = when (name) {
"sin" -> exactTrig(x, 0) ?: kotlin.math.sin(toRad(d))
"cos" -> exactTrig(x, 1) ?: kotlin.math.cos(toRad(d))
"tan" -> {
if (angle == Angle.DEG && x.remainder(BigDecimal(180)).abs().compareTo(BigDecimal(90)) == 0) throw CalcException(ErrorKind.DOMAIN)
exactTrig(x, 2) ?: kotlin.math.tan(toRad(d))
}
"sin⁻¹" -> { if (d < -1 || d > 1) throw CalcException(ErrorKind.DOMAIN); fromRad(kotlin.math.asin(d)) }
"cos⁻¹" -> { if (d < -1 || d > 1) throw CalcException(ErrorKind.DOMAIN); fromRad(kotlin.math.acos(d)) }
"tan⁻¹" -> fromRad(kotlin.math.atan(d))
"ln" -> { if (d <= 0) throw CalcException(ErrorKind.DOMAIN); kotlin.math.ln(d) }
"log" -> { if (d <= 0) throw CalcException(ErrorKind.DOMAIN); exactLog10(x) ?: kotlin.math.log10(d) }
else -> throw CalcException(ErrorKind.SYNTAX)
}
if (r.isNaN() || r.isInfinite()) throw CalcException(ErrorKind.DOMAIN)
return clean(r)
}
/** sin/cos/tan of multiples of 30° and 45° (and π fractions in radians) are exact. */
private fun exactTrig(x: BigDecimal, which: Int): Double? {
val deg = if (angle == Angle.DEG) x else return null
val r = deg.remainder(BigDecimal(360)).let { if (it.signum() < 0) it.add(BigDecimal(360)) else it }
if (r.stripTrailingZeros().scale() > 0) return null
val a = r.toInt()
val table = mapOf(0 to Triple(0.0, 1.0, 0.0), 30 to Triple(.5, Double.NaN, Double.NaN), 90 to Triple(1.0, 0.0, Double.NaN),
150 to Triple(.5, Double.NaN, Double.NaN), 180 to Triple(0.0, -1.0, 0.0), 210 to Triple(-.5, Double.NaN, Double.NaN),
270 to Triple(-1.0, 0.0, Double.NaN), 330 to Triple(-.5, Double.NaN, Double.NaN),
45 to Triple(Double.NaN, Double.NaN, 1.0), 135 to Triple(Double.NaN, Double.NaN, -1.0), 225 to Triple(Double.NaN, Double.NaN, 1.0), 315 to Triple(Double.NaN, Double.NaN, -1.0),
60 to Triple(Double.NaN, .5, Double.NaN), 120 to Triple(Double.NaN, -.5, Double.NaN), 240 to Triple(Double.NaN, -.5, Double.NaN), 300 to Triple(Double.NaN, .5, Double.NaN))
val e = table[a] ?: return null
val v = when (which) { 0 -> e.first; 1 -> e.second; else -> e.third }
return v.takeIf { !it.isNaN() }
}
private fun exactLog10(x: BigDecimal): Double? {
val s = x.stripTrailingZeros()
if (s.unscaledValue() == BigInteger.ONE) return (-s.scale()).toDouble()
return null
}
private fun pow(base: BigDecimal, exp: BigDecimal): BigDecimal {
val e = exp.stripTrailingZeros()
if (e.scale() <= 0 && e.abs() <= BigDecimal(9999)) {
val n = e.toInt()
if (base.signum() == 0 && n < 0) throw CalcException(ErrorKind.DIV_ZERO)
val r = base.pow(kotlin.math.abs(n), mc)
return if (n < 0) BigDecimal.ONE.divide(r, mc) else r
}
if (base.signum() < 0) throw CalcException(ErrorKind.DOMAIN)
val r = Math.pow(base.toDouble(), exp.toDouble())
if (r.isNaN() || r.isInfinite()) throw CalcException(ErrorKind.TOO_BIG)
return clean(r)
}
private fun factorial(x: BigDecimal): BigDecimal {
val s = x.stripTrailingZeros()
if (s.signum() < 0 || s.scale() > 0) throw CalcException(ErrorKind.DOMAIN)
if (s > BigDecimal(1000)) throw CalcException(ErrorKind.TOO_BIG)
var r = BigInteger.ONE
for (i in 2..s.toInt()) r = r.multiply(BigInteger.valueOf(i.toLong()))
return BigDecimal(r)
}
/** Floating results get 15 significant digits, so cos(π) is −1 and not −0.9999999999999999. */
private fun clean(d: Double): BigDecimal = BigDecimal(d).round(MathContext(15, RoundingMode.HALF_EVEN)).let {
if (it.abs() < BigDecimal("1E-14")) BigDecimal.ZERO else it
}
}
companion object {
val PI_BIG = BigDecimal("3.14159265358979323846264338327950288419716939937510")
val E_BIG = BigDecimal("2.71828182845904523536028747135266249775724709369995")
private val LIMIT = BigDecimal("1E5000")
}
}
@@ -0,0 +1,36 @@
package app.rechner.engine
import java.math.BigDecimal
import java.math.MathContext
import java.math.RoundingMode
/** Turns exact results into display text: locale separators, no trailing zeros, scientific when huge or tiny. */
class NumberFormat(private val decimal: Char = ',', private val grouping: Char = '.') {
fun format(v: BigDecimal, maxDigits: Int = 12): String {
if (v.signum() == 0) return "0"
val abs = v.abs()
val sci = abs >= BigDecimal.TEN.pow(maxDigits) || abs < BigDecimal("1E-6")
if (sci) {
val r = v.round(MathContext(maxDigits - 3, RoundingMode.HALF_EVEN))
val exp = r.precision() - r.scale() - 1
val mant = r.movePointLeft(exp).stripTrailingZeros()
return plain(mant) + "E" + (if (exp < 0) "−" else "") + kotlin.math.abs(exp)
}
val intDigits = abs.setScale(0, RoundingMode.DOWN).toPlainString().length
val r = v.setScale((maxDigits - intDigits).coerceAtLeast(0), RoundingMode.HALF_EVEN).stripTrailingZeros()
return plain(r)
}
/** Every digit, for copy, scrolling and the history. */
fun full(v: BigDecimal): String = plain(v.round(MathContext(40, RoundingMode.HALF_EVEN)).stripTrailingZeros())
private fun plain(v: BigDecimal): String {
val s = v.toPlainString()
val neg = s.startsWith("-")
val body = s.removePrefix("-")
val int = body.substringBefore('.')
val frac = body.substringAfter('.', "")
val grouped = int.reversed().chunked(3).joinToString(grouping.toString()).reversed()
return (if (neg) "−" else "") + grouped + if (frac.isNotEmpty()) decimal + frac else ""
}
}
@@ -0,0 +1,379 @@
package app.rechner.ui
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
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.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Backspace
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.History
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.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.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.rechner.R
import app.rechner.data.Graph
import app.rechner.data.HistoryEntry
import app.rechner.data.Prefs
import app.rechner.data.ThemeMode
import app.rechner.engine.Angle
import app.rechner.engine.ErrorKind
import app.rechner.ui.theme.LocalCalcColors
import app.rechner.ui.theme.WType
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun CalculatorScreen(vm: CalculatorViewModel, prefs: Prefs) {
val state by vm.state.collectAsState()
val history by vm.history.collectAsState()
CalculatorContent(
state = state, history = history, prefs = prefs, decimal = vm.decimal,
onKey = vm::key, onBackspace = vm::backspace, onClear = vm::clear, onEquals = vm::equals,
onInsert = vm::insert, onAngle = vm::setAngle, onClearHistory = { vm.clearHistory() },
onMemoryStore = { vm.memoryStore(it) }, currentValue = vm::currentValue,
)
}
@Composable
fun CalculatorContent(
state: CalcState,
history: List<HistoryEntry>,
prefs: Prefs,
decimal: Char,
onKey: (String) -> Unit,
onBackspace: () -> Unit,
onClear: () -> Unit,
onEquals: () -> Unit,
onInsert: (String) -> Unit,
onAngle: (Angle) -> Unit,
onClearHistory: () -> Unit,
onMemoryStore: (String) -> Unit,
currentValue: () -> String,
initialHistoryOpen: Boolean = false,
initialScientific: Boolean = false,
) {
val c = LocalCalcColors.current
var scientific by rememberSaveable { mutableStateOf(initialScientific) }
var inverse by rememberSaveable { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val view = LocalView.current
val tap: (() -> Unit) -> () -> Unit = { f -> { view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); f() } }
BoxWithConstraints(Modifier.fillMaxSize().background(c.bg)) {
val density = LocalDensity.current
val fullPx = with(density) { maxHeight.toPx() }
// the display is a sheet: collapsed above the keypad, pulled down it shows the history
val sheet = remember { Animatable(if (initialHistoryOpen) 1f else 0f) }
var keypadTop by remember { mutableStateOf(maxHeight * .4f) }
Column(Modifier.fillMaxSize()) {
Spacer(Modifier.weight(1f))
Column(Modifier.onGloballyPositioned { keypadTop = with(density) { it.positionInParent().y.toDp() } - 8.dp }) {
FunctionArea(scientific, inverse, prefs.angle, { scientific = !scientific }, { inverse = !inverse }, onAngle, onKey, tap)
Keypad(decimal, onKey, onBackspace, onClear, onEquals, tap, Modifier.navigationBarsPadding())
}
}
// the display reaches down to the function row; pulled down it covers the keypad
val collapsed by animateDpAsState(keypadTop, spring(.9f, 500f), label = "top")
val height = collapsed + (maxHeight - collapsed - 24.dp) * sheet.value
Column(
Modifier.fillMaxWidth().height(height).clip(RoundedCornerShape(bottomStart = 32.dp, bottomEnd = 32.dp)).background(c.display)
.draggable(
rememberDraggableState { d -> scope.launch { sheet.snapTo((sheet.value + d / (fullPx * .6f)).coerceIn(0f, 1f)) } },
Orientation.Vertical,
onDragStopped = { v -> scope.launch { sheet.animateTo(if (v > 300 || (v > -300 && sheet.value > .4f)) 1f else 0f, spring(.9f, 400f)) } },
)
.statusBarsPadding(),
) {
TopBar(
historyOpen = sheet.value > .5f,
onHistory = { scope.launch { sheet.animateTo(if (sheet.value > .5f) 0f else 1f, spring(.9f, 400f)) } },
onClearHistory = onClearHistory, theme = prefs.theme,
)
if (sheet.value > 0.01f) {
HistoryList(history, Modifier.weight(1f)) { onInsert(it); scope.launch { sheet.animateTo(0f, spring(.9f, 400f)) } }
Text(stringResource(R.string.current_expression), style = WType.label, color = c.muted, modifier = Modifier.padding(horizontal = 24.dp))
} else Spacer(Modifier.weight(1f))
Display(state, prefs, onInsert, onMemoryStore, currentValue)
// drag handle
Box(Modifier.fillMaxWidth().padding(vertical = 10.dp), contentAlignment = Alignment.Center) {
Box(Modifier.size(32.dp, 4.dp).clip(RoundedCornerShape(2.dp)).background(c.muted.copy(alpha = .6f)))
}
}
}
}
@Composable
private fun TopBar(historyOpen: Boolean, onHistory: () -> Unit, onClearHistory: () -> Unit, theme: ThemeMode) {
val c = LocalCalcColors.current
var menu by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onHistory) {
Box(Modifier.size(36.dp).clip(CircleShape).background(if (historyOpen) c.op else Color.Transparent), contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.History, stringResource(R.string.history), tint = if (historyOpen) c.onOp else c.muted)
}
}
Spacer(Modifier.weight(1f))
IconButton(onClick = { menu = true }) { Icon(Icons.Rounded.MoreVert, stringResource(R.string.more), tint = c.muted) }
DropdownMenu(menu, { menu = false }) {
DropdownMenuItem(text = { Text(stringResource(R.string.clear_history)) }, onClick = { menu = false; onClearHistory() })
val next = when (theme) { ThemeMode.SYSTEM -> ThemeMode.DARK; ThemeMode.DARK -> ThemeMode.LIGHT; ThemeMode.LIGHT -> ThemeMode.SYSTEM }
val label = mapOf(ThemeMode.SYSTEM to R.string.theme_system, ThemeMode.DARK to R.string.theme_dark, ThemeMode.LIGHT to R.string.theme_light)
DropdownMenuItem(
text = { Text(stringResource(R.string.theme) + ": " + stringResource(label.getValue(theme))) },
onClick = { scope.launch { Graph.store.setTheme(next) } },
)
}
}
}
@Composable
private fun HistoryList(history: List<HistoryEntry>, modifier: Modifier, onPick: (String) -> Unit) {
val c = LocalCalcColors.current
val state = rememberLazyListState()
LaunchedEffect(history.size) { if (history.isNotEmpty()) state.scrollToItem(history.size + countDays(history)) }
if (history.isEmpty()) {
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Text(stringResource(R.string.no_history), style = WType.body, color = c.muted) }
return
}
val res = LocalContext.current.resources
LazyColumn(modifier.fillMaxWidth(), state = state, verticalArrangement = Arrangement.Bottom) {
var lastDay: LocalDate? = null
history.forEach { h ->
val day = Instant.ofEpochMilli(h.createdAt).atZone(ZoneId.systemDefault()).toLocalDate()
if (day != lastDay) {
val d = day
item(key = "d$d") {
val label = when (d) {
LocalDate.now() -> res.getString(R.string.today)
LocalDate.now().minusDays(1) -> res.getString(R.string.yesterday)
else -> d.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
}
Text(label, style = WType.label, color = c.muted, modifier = Modifier.padding(start = 24.dp, top = 16.dp, bottom = 4.dp))
}
lastDay = day
}
item(key = h.id) {
Column(
Modifier.fillMaxWidth().clickable { onPick(h.result) }.padding(horizontal = 24.dp, vertical = 8.dp),
horizontalAlignment = Alignment.End,
) {
Text(h.expression, style = WType.historyExpr, color = c.muted, maxLines = 1)
Text(h.result, style = WType.historyResult, color = c.text, maxLines = 1)
}
}
}
}
}
private fun countDays(h: List<HistoryEntry>) = h.map { Instant.ofEpochMilli(it.createdAt).atZone(ZoneId.systemDefault()).toLocalDate() }.distinct().size
@Composable
private fun Display(state: CalcState, prefs: Prefs, onInsert: (String) -> Unit, onMemoryStore: (String) -> Unit, currentValue: () -> String) {
val c = LocalCalcColors.current
val context = LocalContext.current
var menu by remember { mutableStateOf(false) }
val clipboard = context.getSystemService(ClipboardManager::class.java)
val scroll = rememberScrollState()
LaunchedEffect(state.expression) { scroll.scrollTo(scroll.maxValue) }
Column(Modifier.fillMaxWidth().padding(horizontal = 24.dp), horizontalAlignment = Alignment.End) {
Box {
val text = state.expression.ifEmpty { " " }
// shrink with length, then scroll
val size = when { text.length <= 7 -> 72.sp; text.length <= 10 -> 56.sp; text.length <= 14 -> 44.sp; else -> 36.sp }
Row(Modifier.horizontalScroll(scroll)) {
Text(
text, style = WType.expr.copy(fontSize = size, lineHeight = size * 1.1f, fontFeatureSettings = "tnum"), color = c.text, maxLines = 1,
modifier = Modifier.combinedClickable(onClick = {}, onLongClick = { menu = true }).semantics { contentDescription = speak(state.expression) },
)
}
DropdownMenu(menu, { menu = false }, shape = RoundedCornerShape(24.dp)) {
DropdownMenuItem(text = { Text(stringResource(R.string.copy)) }, onClick = {
menu = false; clipboard.setPrimaryClip(ClipData.newPlainText("Ergebnis", currentValue()))
})
DropdownMenuItem(text = { Text(stringResource(R.string.paste)) }, onClick = {
menu = false; clipboard.primaryClip?.getItemAt(0)?.coerceToText(context)?.toString()?.let(onInsert)
})
DropdownMenuItem(text = { Text("MS") }, onClick = { menu = false; onMemoryStore(currentValue()) })
if (prefs.memory != null) DropdownMenuItem(text = { Text("MR " + prefs.memory) }, onClick = { menu = false; onInsert(prefs.memory) })
}
}
val second = when {
state.error != null -> errorText(state.error)
state.showingResult -> ""
else -> state.preview
}
Text(
second.ifEmpty { " " }, style = if (state.error != null) WType.fn else WType.result.copy(fontFeatureSettings = "tnum"),
color = if (state.error != null) c.error else c.result, maxLines = 1, textAlign = TextAlign.End,
)
}
}
@Composable
private fun errorText(e: ErrorKind) = stringResource(
when (e) { ErrorKind.DIV_ZERO -> R.string.err_div_zero; ErrorKind.DOMAIN -> R.string.err_domain; ErrorKind.TOO_BIG -> R.string.err_too_big; ErrorKind.SYNTAX -> R.string.err_syntax },
)
private fun speak(e: String) = e.replace("×", " mal ").replace("÷", " geteilt durch ").replace("−", " minus ").replace("+", " plus ").replace("√", " Wurzel ").replace("π", " Pi ")
@Composable
private fun FunctionArea(
expanded: Boolean, inverse: Boolean, angle: Angle,
onToggle: () -> Unit, onInverse: () -> Unit, onAngle: (Angle) -> Unit, onKey: (String) -> Unit, tap: (() -> Unit) -> () -> Unit,
) {
val c = LocalCalcColors.current
Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
listOf("√" to "√", "π" to "π", "^" to "^", "!" to "!").forEach { (label, key) ->
FnKey(label, expanded, tap { onKey(key) })
}
Box(Modifier.padding(start = 6.dp).size(40.dp).clip(CircleShape).background(c.fn).clickable(onClick = onToggle), contentAlignment = Alignment.Center) {
Icon(if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, stringResource(R.string.more_functions), tint = c.onFn)
}
}
AnimatedVisibility(expanded, enter = expandVertically(spring(.9f, 500f)), exit = shrinkVertically(spring(.9f, 500f))) {
Column {
Row {
FnKey(if (angle == Angle.DEG) "Deg" else "Rad", true, tap { onAngle(if (angle == Angle.DEG) Angle.RAD else Angle.DEG) })
if (inverse) { FnKey("sin⁻¹", true, tap { onKey("sin⁻¹(") }); FnKey("cos⁻¹", true, tap { onKey("cos⁻¹(") }); FnKey("tan⁻¹", true, tap { onKey("tan⁻¹(") }) }
else { FnKey("sin", true, tap { onKey("sin(") }); FnKey("cos", true, tap { onKey("cos(") }); FnKey("tan", true, tap { onKey("tan(") }) }
Spacer(Modifier.width(46.dp))
}
Row {
FnKey("Inv", true, onInverse, selected = inverse)
if (inverse) { FnKey("eˣ", true, tap { onKey("e^") }); FnKey("10ˣ", true, tap { onKey("10^") }); FnKey("x²", true, tap { onKey("^2") }) }
else { FnKey("e", true, tap { onKey("e") }); FnKey("ln", true, tap { onKey("ln(") }); FnKey("log", true, tap { onKey("log(") }) }
Spacer(Modifier.width(46.dp))
}
}
}
}
}
@Composable
private fun RowScope.FnKey(label: String, tonal: Boolean, onClick: () -> Unit, selected: Boolean = false) {
val c = LocalCalcColors.current
Box(
Modifier.weight(1f).padding(4.dp).height(44.dp).clip(RoundedCornerShape(22.dp))
.background(if (selected) c.op else if (tonal) c.fn else Color.Transparent).clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) { Text(label, style = WType.fn, color = if (selected) c.onOp else c.onFn) }
}
@Composable
private fun Keypad(
decimal: Char, onKey: (String) -> Unit, onBackspace: () -> Unit, onClear: () -> Unit, onEquals: () -> Unit,
tap: (() -> Unit) -> () -> Unit, modifier: Modifier,
) {
val c = LocalCalcColors.current
data class K(val label: String, val bg: Color, val fg: Color, val action: () -> Unit, val icon: ImageVector? = null, val long: (() -> Unit)? = null, val desc: String = label)
val d = K("", c.digit, c.onDigit, {})
fun digit(s: String) = d.copy(label = s, action = tap { onKey(s) })
fun op(s: String, desc: String) = K(s, c.op, c.onOp, tap { onKey(s) }, desc = desc)
val rows = listOf(
listOf(K("AC", c.ac, c.onAc, tap(onClear), desc = "Alles löschen"), op("()", "Klammer"), op("%", "Prozent"), op("÷", "geteilt durch")),
listOf(digit("7"), digit("8"), digit("9"), op("×", "mal")),
listOf(digit("4"), digit("5"), digit("6"), op("−", "minus")),
listOf(digit("1"), digit("2"), digit("3"), op("+", "plus")),
listOf(digit("0"), digit(decimal.toString()), K("", c.digit, c.onDigit, tap(onBackspace), Icons.AutoMirrored.Rounded.Backspace, long = onClear, desc = "Löschen"),
K("=", c.eq, c.onEq, tap(onEquals), desc = "gleich")),
)
Column(modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
rows.forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
row.forEach { k -> Key(k.label, k.bg, k.fg, k.icon, k.desc, k.action, k.long, Modifier.weight(1f)) }
}
}
}
}
/** Circle at rest, squircle while pressed: the expressive press. */
@Composable
private fun Key(label: String, bg: Color, fg: Color, icon: ImageVector?, desc: String, onClick: () -> Unit, onLong: (() -> Unit)?, modifier: Modifier) {
val source = remember { MutableInteractionSource() }
val pressed by source.collectIsPressedAsState()
val radius by animateDpAsState(if (pressed) 22.dp else 100.dp, spring(.6f, 800f), label = "r")
Box(
modifier.aspectRatio(1.18f).clip(RoundedCornerShape(radius)).background(bg)
.combinedClickable(interactionSource = source, indication = null, onClick = onClick, onLongClick = onLong)
.semantics { contentDescription = desc },
contentAlignment = Alignment.Center,
) {
if (icon != null) Icon(icon, null, tint = fg, modifier = Modifier.size(30.dp))
else Text(label, style = WType.key, color = fg)
}
}
@@ -0,0 +1,115 @@
package app.rechner.ui
import androidx.activity.ComponentActivity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import app.rechner.data.Graph
import app.rechner.data.HistoryEntry
import app.rechner.engine.Angle
import app.rechner.engine.Editor
import app.rechner.engine.Engine
import app.rechner.engine.ErrorKind
import app.rechner.engine.NumberFormat
import app.rechner.engine.Outcome
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.text.DecimalFormatSymbols
data class CalcState(
val expression: String = "",
/** live preview, empty when there is nothing new to show */
val preview: String = "",
val error: ErrorKind? = null,
/** after "=": the expression is a result and the next digit starts over */
val showingResult: Boolean = false,
val previous: String = "",
)
class CalculatorViewModel : ViewModel() {
val decimal: Char = DecimalFormatSymbols.getInstance().decimalSeparator
private val grouping: Char = DecimalFormatSymbols.getInstance().groupingSeparator.let { if (it == decimal) ' ' else it }
private val engine = Engine(decimal)
val format = NumberFormat(decimal, grouping)
private var angle = Angle.DEG
private val _state = MutableStateFlow(CalcState())
val state: StateFlow<CalcState> = _state
val history: StateFlow<List<HistoryEntry>> = Graph.history.all().stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
init {
viewModelScope.launch {
val p = Graph.store.prefs.first()
angle = p.angle
update(p.expression)
}
}
fun setAngle(a: Angle) {
angle = a
viewModelScope.launch { Graph.store.setAngle(a) }
update(_state.value.expression, keepResult = _state.value.showingResult)
}
fun key(k: String) {
val s = _state.value
val base = when {
// a digit or function after "=" starts a new calculation; an operator continues with the result
s.showingResult && (k.first().isDigit() || k == decimal.toString() || k.endsWith("(") || k in setOf("√", "π", "e", "()")) -> ""
s.error != null && s.showingResult -> ""
else -> s.expression
}
update(Editor.input(base, k, decimal))
}
fun backspace() = update(if (_state.value.showingResult) "" else Editor.backspace(_state.value.expression))
fun clear() = update("")
fun insert(text: String) = update(_state.value.let { if (it.showingResult) "" else it.expression } + sanitize(text))
fun equals() {
val s = _state.value
if (s.expression.isEmpty() || s.showingResult) return
when (val r = engine.evaluate(s.expression, angle, lenient = true)) {
is Outcome.Value -> {
val result = format.format(r.value, 15)
viewModelScope.launch { Graph.history.insert(HistoryEntry(expression = s.expression, result = result)); Graph.history.trim() }
_state.value = CalcState(expression = result, previous = s.expression, showingResult = true)
persist(result)
}
is Outcome.Error -> _state.value = s.copy(error = r.kind, preview = "")
Outcome.Empty -> Unit
}
}
fun clearHistory() = viewModelScope.launch { Graph.history.clear() }
fun memoryStore(value: String) = viewModelScope.launch { Graph.store.setMemory(value) }
/** The value currently shown: result after "=", else the live preview, else the expression. */
fun currentValue(): String = _state.value.let { if (it.showingResult) it.expression else it.preview.ifEmpty { it.expression } }
private fun update(expr: String, keepResult: Boolean = false) {
val r = engine.evaluate(expr, angle, lenient = true)
val preview = when (r) {
is Outcome.Value -> format.format(r.value).takeIf { it != expr && hasOperation(expr) } ?: ""
else -> ""
}
_state.value = CalcState(expression = expr, preview = preview, showingResult = keepResult)
persist(expr)
}
private fun hasOperation(e: String) = e.any { it in "+−×÷^!%√πe(" } || e.contains("sin") || e.contains("cos") || e.contains("tan") || e.contains("ln") || e.contains("log")
private fun persist(expr: String) = viewModelScope.launch { Graph.store.setExpression(expr) }
/** Paste keeps only what the keypad could type. */
private fun sanitize(t: String) = t.replace('*', '×').replace('/', '÷').replace('-', '−')
.filter { it.isDigit() || it == decimal || it == grouping || it in "+−×÷^!%√πe()E" || it.isLetter() }
companion object {
fun get(activity: ComponentActivity) = ViewModelProvider(activity)[CalculatorViewModel::class.java]
}
}
@@ -0,0 +1,61 @@
package app.rechner.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import app.rechner.data.ThemeMode
/** Key and display roles; from the wallpaper on Android 12+, from tokens otherwise. */
@Immutable
data class CalcColors(
val bg: Color, val display: Color, val text: Color, val muted: Color, val result: Color, val error: Color,
val digit: Color, val onDigit: Color, val op: Color, val onOp: Color, val ac: Color, val onAc: Color,
val eq: Color, val onEq: Color, val fn: Color, val onFn: Color,
)
private val LightCalc = CalcColors(
WColor.bg, WColor.display, WColor.text, WColor.textMuted, WColor.result, WColor.error,
WColor.keyDigit, WColor.onKeyDigit, WColor.keyOp, WColor.onKeyOp, WColor.keyAc, WColor.onKeyAc,
WColor.keyEq, WColor.onKeyEq, WColor.keyFn, WColor.onKeyFn,
)
private val DarkCalc = CalcColors(
WColor.darkBg, WColor.darkDisplay, WColor.darkText, WColor.darkTextMuted, WColor.darkResult, WColor.darkError,
WColor.darkKeyDigit, WColor.darkOnKeyDigit, WColor.darkKeyOp, WColor.darkOnKeyOp, WColor.darkKeyAc, WColor.darkOnKeyAc,
WColor.darkKeyEq, WColor.darkOnKeyEq, WColor.darkKeyFn, WColor.darkOnKeyFn,
)
private fun fromScheme(s: ColorScheme, dark: Boolean) = CalcColors(
bg = s.surface, display = s.surfaceContainerHigh, text = s.onSurface, muted = s.onSurfaceVariant, result = s.onSurfaceVariant, error = s.error,
digit = if (dark) s.surfaceContainer else s.surfaceContainerLow, onDigit = s.onSurface,
op = s.secondaryContainer, onOp = s.onSecondaryContainer,
ac = s.tertiaryContainer, onAc = s.onTertiaryContainer,
eq = if (dark) s.primary else s.primaryContainer, onEq = if (dark) s.onPrimary else s.onPrimaryContainer,
fn = s.surfaceContainerHighest, onFn = s.onSurface,
)
val LocalCalcColors = staticCompositionLocalOf { LightCalc }
@Composable
fun CalcTheme(mode: ThemeMode = ThemeMode.SYSTEM, dynamic: Boolean = true, content: @Composable () -> Unit) {
val dark = when (mode) { ThemeMode.SYSTEM -> isSystemInDarkTheme(); ThemeMode.LIGHT -> false; ThemeMode.DARK -> true }
val context = LocalContext.current
val useDynamic = dynamic && Build.VERSION.SDK_INT >= 31
val scheme = when {
useDynamic -> if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
dark -> darkColorScheme()
else -> lightColorScheme()
}
val calc = if (useDynamic) fromScheme(scheme, dark) else if (dark) DarkCalc else LightCalc
CompositionLocalProvider(LocalCalcColors provides calc) { MaterialTheme(colorScheme = scheme, content = content) }
}
@@ -0,0 +1,75 @@
// Generated from tokens.json by gen_theme.py. Do not edit by hand.
package app.rechner.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(0xFFF9F9FF)
val display = Color(0xFFE1E5EE)
val sheet = Color(0xFFE8EBF2)
val text = Color(0xFF191C20)
val textMuted = Color(0xFF43474E)
val result = Color(0xFF5A5F68)
val keyDigit = Color(0xFFEEF0F5)
val onKeyDigit = Color(0xFF191C20)
val keyOp = Color(0xFFD6E3F5)
val onKeyOp = Color(0xFF0E1D2E)
val keyAc = Color(0xFFEAD9FF)
val onKeyAc = Color(0xFF25113D)
val keyEq = Color(0xFFCDE3FB)
val onKeyEq = Color(0xFF06233D)
val keyFn = Color(0xFFDDE3EC)
val onKeyFn = Color(0xFF191C20)
val error = Color(0xFFBA1A1A)
val darkBg = Color(0xFF0B1414)
val darkDisplay = Color(0xFF283535)
val darkSheet = Color(0xFF1E2A2A)
val darkText = Color(0xFFE0E6E6)
val darkTextMuted = Color(0xFFBEC8C8)
val darkResult = Color(0xFF9FB0B0)
val darkKeyDigit = Color(0xFF172121)
val darkOnKeyDigit = Color(0xFFDDE4E4)
val darkKeyOp = Color(0xFFA3D1D3)
val darkOnKeyOp = Color(0xFF002F31)
val darkKeyAc = Color(0xFF8FD5E8)
val darkOnKeyAc = Color(0xFF00303B)
val darkKeyEq = Color(0xFF00E0CC)
val darkOnKeyEq = Color(0xFF00332E)
val darkKeyFn = Color(0xFF253232)
val darkOnKeyFn = Color(0xFFDDE4E4)
val darkError = Color(0xFFFFB4AB)
}
object WType {
val label = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.W500)
val body = TextStyle(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.W400)
val fn = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.W400)
val key = TextStyle(fontSize = 34.sp, lineHeight = 40.sp, fontWeight = FontWeight.W400)
val result = TextStyle(fontSize = 36.sp, lineHeight = 44.sp, fontWeight = FontWeight.W400)
val expr = TextStyle(fontSize = 72.sp, lineHeight = 80.sp, fontWeight = FontWeight.W400)
val exprMin = TextStyle(fontSize = 36.sp, lineHeight = 44.sp, fontWeight = FontWeight.W400)
val historyExpr = TextStyle(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.W400)
val historyResult = TextStyle(fontSize = 28.sp, lineHeight = 34.sp, fontWeight = FontWeight.W400)
}
object WSpace {
val s0 = 0.dp
val s4 = 4.dp
val s8 = 8.dp
val s12 = 12.dp
val s16 = 16.dp
val s24 = 24.dp
val s32 = 32.dp
}
object WRadius {
val display = 32.dp
val key = 999.dp
val keyPressed = 24.dp
val menu = 24.dp
val pill = 999.dp
}
@@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"><path android:fillColor="#FF1E3A5F" android:pathData="M0,0h108v108h-108z"/></vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#FFD6E3FF" android:pathData="M37,30h8a7,7 0 0 1 7,7v8a7,7 0 0 1 -7,7h-8a7,7 0 0 1 -7,-7v-8a7,7 0 0 1 7,-7z"/>
<path android:fillColor="#FFD6E3FF" android:pathData="M63,30h8a7,7 0 0 1 7,7v8a7,7 0 0 1 -7,7h-8a7,7 0 0 1 -7,-7v-8a7,7 0 0 1 7,-7z"/>
<path android:fillColor="#FFD6E3FF" android:pathData="M37,56h8a7,7 0 0 1 7,7v8a7,7 0 0 1 -7,7h-8a7,7 0 0 1 -7,-7v-8a7,7 0 0 1 7,-7z"/>
<path android:fillColor="#FFFFB86B" android:pathData="M63,56h8a7,7 0 0 1 7,7v8a7,7 0 0 1 -7,7h-8a7,7 0 0 1 -7,-7v-8a7,7 0 0 1 7,-7z"/>
<path android:strokeColor="#FF1E3A5F" android:strokeWidth="2.6" android:strokeLineCap="round" android:pathData="M36,41h10M41,36v10"/>
<path android:strokeColor="#FF1E3A5F" android:strokeWidth="2.6" android:strokeLineCap="round" android:pathData="M62,41h10"/>
<path android:strokeColor="#FF1E3A5F" android:strokeWidth="2.6" android:strokeLineCap="round" android:pathData="M37.5,63.5l7,7M44.5,63.5l-7,7"/>
<path android:strokeColor="#FF5A2E00" android:strokeWidth="2.6" android:strokeLineCap="round" android:pathData="M62,64h10M62,70h10"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#FF000000" android:pathData="M37,30h8a7,7 0 0 1 7,7v8a7,7 0 0 1 -7,7h-8a7,7 0 0 1 -7,-7v-8a7,7 0 0 1 7,-7z"/>
<path android:fillColor="#FF000000" android:pathData="M63,30h8a7,7 0 0 1 7,7v8a7,7 0 0 1 -7,7h-8a7,7 0 0 1 -7,-7v-8a7,7 0 0 1 7,-7z"/>
<path android:fillColor="#FF000000" android:pathData="M37,56h8a7,7 0 0 1 7,7v8a7,7 0 0 1 -7,7h-8a7,7 0 0 1 -7,-7v-8a7,7 0 0 1 7,-7z"/>
<path android:fillColor="#FF000000" android:pathData="M63,56h8a7,7 0 0 1 7,7v8a7,7 0 0 1 -7,7h-8a7,7 0 0 1 -7,-7v-8a7,7 0 0 1 7,-7z"/>
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" android:pathData="M36,41h10M41,36v10"/>
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" android:pathData="M62,41h10"/>
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" android:pathData="M37.5,63.5l7,7M44.5,63.5l-7,7"/>
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" android:pathData="M62,64h10M62,70h10"/>
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="history">Verlauf</string>
<string name="more">Weitere Optionen</string>
<string name="more_functions">Weitere Funktionen</string>
<string name="clear_history">Verlauf löschen</string>
<string name="no_history">Noch keine Rechnungen</string>
<string name="current_expression">Aktuelle Eingabe</string>
<string name="today">Heute</string>
<string name="yesterday">Gestern</string>
<string name="copy">Kopieren</string>
<string name="paste">Einfügen</string>
<string name="theme">Design</string>
<string name="theme_system">System</string>
<string name="theme_light">Hell</string>
<string name="theme_dark">Dunkel</string>
<string name="err_div_zero">Division durch null geht nicht</string>
<string name="err_domain">Nicht definiert</string>
<string name="err_too_big">Zahl zu groß</string>
<string name="err_syntax">Ungültige Eingabe</string>
</resources>
@@ -0,0 +1,52 @@
package app.rechner
import app.rechner.engine.Angle
import app.rechner.engine.Engine
import app.rechner.engine.ErrorKind
import app.rechner.engine.NumberFormat
import app.rechner.engine.Outcome
import org.junit.Assert.assertEquals
import org.junit.Test
class EngineTest {
private val e = Engine(',')
private val f = NumberFormat(',', '.')
private fun calc(s: String, angle: Angle = Angle.DEG): String = when (val r = e.evaluate(s, angle)) {
is Outcome.Value -> f.format(r.value)
is Outcome.Error -> r.kind.name
Outcome.Empty -> ""
}
@Test fun basics() {
assertEquals("46", calc("12+34"))
assertEquals("14", calc("2+3×4"))
assertEquals("20", calc("(2+3)×4"))
assertEquals("−625", calc("−5×125"))
assertEquals("2,5", calc("5÷2"))
assertEquals("118", calc("69+49"))
}
@Test fun exactDecimals() { assertEquals("0,3", calc("0,1+0,2")); assertEquals("1", calc("1÷3×3")) }
@Test fun grouping() { assertEquals("7.720", calc("7.775−55")); assertEquals("2.025", calc("2.024+1")) }
@Test fun percent() {
assertEquals("55", calc("50+10%")); assertEquals("45", calc("50−10%"))
assertEquals("5", calc("50×10%")); assertEquals("0,1", calc("10%"))
}
@Test fun powersAndRoots() {
assertEquals("−4", calc("−2^2")); assertEquals("512", calc("2^3^2")); assertEquals("3", calc("√9"))
assertEquals("0,5", calc("2^−1")); assertEquals("1,41421356237", calc("√2"))
}
@Test fun implicitMultiplication() { assertEquals("6,28318530718", calc("2π")); assertEquals("12", calc("3(4)")); assertEquals("6", calc("2√9")) }
@Test fun trig() {
assertEquals("0,5", calc("sin(30)")); assertEquals("−1", calc("cos(180)")); assertEquals("1", calc("tan(45)"))
assertEquals("−1", calc("cos(π)", Angle.RAD)); assertEquals("30", calc("sin⁻¹(0,5)"))
assertEquals("DOMAIN", calc("tan(90)"))
}
@Test fun logs() { assertEquals("2", calc("log(100)")); assertEquals("1", calc("ln(e)")); assertEquals("DOMAIN", calc("ln(0)")) }
@Test fun factorial() { assertEquals("120", calc("5!")); assertEquals("9,33262154E157", calc("100!")); assertEquals("DOMAIN", calc("2,5!")) }
@Test fun errors() { assertEquals("DIV_ZERO", calc("1÷0")); assertEquals("DOMAIN", calc("√(−1)")) }
@Test fun lenientInput() {
assertEquals("5", calc("(2+3")); assertEquals("5", calc("5+")); assertEquals("", calc("")); assertEquals("", calc("×"))
assertEquals("7", calc("sin(30)+6,5"))
}
@Test fun smallNumbers() { assertEquals("1E−7", calc("1÷10000000")) }
}
@@ -0,0 +1,47 @@
package app.rechner
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import app.rechner.data.HistoryEntry
import app.rechner.data.Prefs
import app.rechner.data.ThemeMode
import app.rechner.engine.Angle
import app.rechner.ui.CalcState
import app.rechner.ui.CalculatorContent
import app.rechner.ui.theme.CalcTheme
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [35], qualifiers = "de-rDE-w412dp-h915dp-xxhdpi", application = android.app.Application::class)
class ScreenshotTest {
@get:Rule val compose = createComposeRule()
private val now = System.currentTimeMillis()
private val history = listOf(
HistoryEntry(1, "69+49", "118", now - 86_400_000), HistoryEntry(2, "16×0,5", "8", now - 4_000_000),
HistoryEntry(3, "−5×125", "−625", now - 3_000_000), HistoryEntry(4, "2.024+1", "2.025", now - 60_000),
)
private fun shot(name: String, dark: Boolean, state: CalcState, history: Boolean = false, sci: Boolean = false, prefs: Prefs = Prefs()) {
compose.setContent {
CalcTheme(if (dark) ThemeMode.DARK else ThemeMode.LIGHT, dynamic = false) {
CalculatorContent(state, this.history, prefs, ',', {}, {}, {}, {}, {}, {}, {}, {}, { "" }, initialHistoryOpen = history, initialScientific = sci)
}
}
compose.onRoot().captureRoboImage("../../replica/rechner/clone-screens/$name.png")
}
@Test fun s01_empty_dark() = shot("S01-empty-dark", true, CalcState())
@Test fun s01_typing_light() = shot("S01-typing-light", false, CalcState("7.775−55", "7.720"))
@Test fun s01_result_dark() = shot("S01-result-dark", true, CalcState("118", showingResult = true))
@Test fun s01_error() = shot("S01-error-light", false, CalcState("1÷0", error = app.rechner.engine.ErrorKind.DIV_ZERO))
@Test fun s02_scientific_dark() = shot("S02-scientific-dark", true, CalcState("cos(π)", "−1"), sci = true, prefs = Prefs(angle = Angle.RAD))
@Test fun s03_history_dark() = shot("S03-history-dark", true, CalcState("cos(π)", "−1"), history = true)
}
+7
View File
@@ -0,0 +1,7 @@
plugins {
id("com.android.application") version "8.13.0" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
id("com.google.devtools.ksp") version "2.2.20-2.0.3" apply false
id("io.github.takahirom.roborazzi") version "1.46.1" apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+16
View File
@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Rechner"
include(":app")
+43
View File
@@ -0,0 +1,43 @@
# Architecture: Rechner (Android, ohne Google)
Same conventions as `wetter/` and `uhr/`: one native app, no server, **no network permission**.
## Stack
| Schicht | Wahl | warum |
| --- | --- | --- |
| UI | Kotlin + Compose, Material 3 Expressive, dynamic colour | same look and pipeline as the other apps |
| Rechenkern | own recursive-descent parser → AST → evaluator on `BigDecimal` (MathContext 50 digits), integers stay exact `BigInteger` | no floating-point surprises (0.1+0.2 = 0.3), 100! exact; pure Kotlin, unit-tested on the JVM |
| Funktionen | sin/cos/tan/ln/log/eˣ/√/^ via `Math` on doubles only where unavoidable, results rounded to 15 significant digits, exact values snapped (sin 30° = 0.5, cos π = −1) | readable answers without fake precision |
| Persistenz | Room (history) + DataStore (memory, angle unit, current expression) | – |
| Build/Verteilung | minSdk 29, `scripts/release.sh rechner` (Obtainium) | – |
## Schema
`schema.sql`: 1 table `history`. Single user, app-private storage.
## Interne API
| Aufruf | macht | Flow |
| --- | --- | --- |
| `Engine.evaluate(expr, angle)` | parse + evaluate, tolerant of unfinished input (auto-closes parentheses, ignores a trailing operator) | F01, F02 |
| `Editor.input(key)` | cursor-aware editing: digits, operators (replace a trailing operator), smart parentheses, %, functions insert `sin(`, backspace removes whole tokens | F01, F02 |
| `Calc.equals()` | evaluate, store history row, result becomes the expression | F01 |
| `History.observe()/clear()` | grouped by local day | F03 |
| `Memory.store(value)/recall()` | DataStore | F05 |
| `Clipboard.copy/paste` | system clipboard; paste is sanitised to calculator tokens | F04 |
## Was beißt
- **Precision:** division keeps 50 digits internally; display rounds to fit and strips trailing zeros; repeating decimals shown truncated with an ellipsis rather than rounded up wrongly.
- **Percent:** `50+10%` = 55 (percent of the left operand for + and −), `50×10%` = 5; standalone `10%` = 0.1.
- **Locale:** German uses comma decimal and dot grouping; the parser accepts the locale separator only.
- **Big results:** 100! has 158 digits; display shows them scientifically (1,5E157) and the full value on scroll.
- **Domain errors:** √(−1), ln 0, tan 90°, 0÷0, factorial of non-integers → error text, never a crash.
## Build-Reihenfolge
1. Vertical slice (S01): engine + keypad + live result + equals; engine unit tests.
2. Must: scientific row and panel (S02), Deg/Rad, Inv, errors, auto-shrinking display, history (S03), copy/paste (S04), expressive keys, dynamic colour.
3. Should: memory, implicit multiplication, pull-down history, haptics, big numbers, locale separators, clear history.
4. Could: landscape layout with all functions.
+13
View File
@@ -0,0 +1,13 @@
# Build log: Rechner
App in `rechner/`. Engine unit tests: 12 suites of cases (exact decimals, percent rules, powers, implicit multiplication, trig incl. exact angles, logs, factorial, errors, lenient input). Screenshots via Roborazzi → `replica/rechner/clone-screens/`.
| ID | date | status | missing | notes |
| --- | --- | --- | --- | --- |
| S01 Calculator | 2026-10-17 | done | – | verified on device: 12+34 live preview 46, = makes 46 the expression; display grows to meet the keypad |
| S02 Scientific | 2026-10-17 | done | – | verified on device: Rad, cos(π) → −1 |
| S03 History | 2026-10-17 | done | – | verified on device: pull down shows "Heute" group and the current expression |
| S04 Context menu | 2026-10-17 | done | cut | copy, paste, MS, MR |
| S05 Settings | 2026-10-17 | done | – | theme and clear history in the overflow menu |
Not done: landscape layout; scrolling through all digits of very long results.
Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

+75
View File
@@ -0,0 +1,75 @@
// Generated from tokens.json by gen_theme.py. Do not edit by hand.
package app.rechner.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(0xFFF9F9FF)
val display = Color(0xFFE1E5EE)
val sheet = Color(0xFFE8EBF2)
val text = Color(0xFF191C20)
val textMuted = Color(0xFF43474E)
val result = Color(0xFF5A5F68)
val keyDigit = Color(0xFFEEF0F5)
val onKeyDigit = Color(0xFF191C20)
val keyOp = Color(0xFFD6E3F5)
val onKeyOp = Color(0xFF0E1D2E)
val keyAc = Color(0xFFEAD9FF)
val onKeyAc = Color(0xFF25113D)
val keyEq = Color(0xFFCDE3FB)
val onKeyEq = Color(0xFF06233D)
val keyFn = Color(0xFFDDE3EC)
val onKeyFn = Color(0xFF191C20)
val error = Color(0xFFBA1A1A)
val darkBg = Color(0xFF0B1414)
val darkDisplay = Color(0xFF283535)
val darkSheet = Color(0xFF1E2A2A)
val darkText = Color(0xFFE0E6E6)
val darkTextMuted = Color(0xFFBEC8C8)
val darkResult = Color(0xFF9FB0B0)
val darkKeyDigit = Color(0xFF172121)
val darkOnKeyDigit = Color(0xFFDDE4E4)
val darkKeyOp = Color(0xFFA3D1D3)
val darkOnKeyOp = Color(0xFF002F31)
val darkKeyAc = Color(0xFF8FD5E8)
val darkOnKeyAc = Color(0xFF00303B)
val darkKeyEq = Color(0xFF00E0CC)
val darkOnKeyEq = Color(0xFF00332E)
val darkKeyFn = Color(0xFF253232)
val darkOnKeyFn = Color(0xFFDDE4E4)
val darkError = Color(0xFFFFB4AB)
}
object WType {
val label = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.W500)
val body = TextStyle(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.W400)
val fn = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.W400)
val key = TextStyle(fontSize = 34.sp, lineHeight = 40.sp, fontWeight = FontWeight.W400)
val result = TextStyle(fontSize = 36.sp, lineHeight = 44.sp, fontWeight = FontWeight.W400)
val expr = TextStyle(fontSize = 72.sp, lineHeight = 80.sp, fontWeight = FontWeight.W400)
val exprMin = TextStyle(fontSize = 36.sp, lineHeight = 44.sp, fontWeight = FontWeight.W400)
val historyExpr = TextStyle(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.W400)
val historyResult = TextStyle(fontSize = 28.sp, lineHeight = 34.sp, fontWeight = FontWeight.W400)
}
object WSpace {
val s0 = 0.dp
val s4 = 4.dp
val s8 = 8.dp
val s12 = 12.dp
val s16 = 16.dp
val s24 = 24.dp
val s32 = 32.dp
}
object WRadius {
val display = 32.dp
val key = 999.dp
val keyPressed = 24.dp
val menu = 24.dp
val pill = 999.dp
}
+26
View File
@@ -0,0 +1,26 @@
# Komponenten: Rechner
```
Display (S01)
expression right aligned, expr 72sp shrinking to expr-min as it grows, then scrolls horizontally; cursor; selectable (long press → S04)
result live preview below (result 36sp, muted); on "=" it animates up into the expression
states empty (blinking cursor), typing, final, error (error colour, message), overflow (scroll)
tokens display bg, radius display at the bottom corners, drag handle 32×4 at the bottom edge
FunctionRow (S01) / ScientificPanel (S02)
collapsed √ π ^ ! as text keys + chevron button (circle)
expanded tonal pills: √ π ^ ! / Deg|Rad sin cos tan / Inv e ln log; Inv toggles labels (sin⁻¹ cos⁻¹ tan⁻¹ / eˣ 10ˣ x²)
motion expand with the keypad sliding down (sheet spring)
Key
variants digit, op (÷ × − +, ( ), %), ac, eq, fn
shape circle at rest → squircle (key-pressed radius) while pressed, spring back; haptic KEYBOARD_TAP
a11y content descriptions in words ("geteilt durch", "Klammer", "Wurzel"), backspace long-press = clear
HistorySheet (S03)
pull the display down (or ⏱ button) to reveal; groups by day ("Heute", "Gestern", date); row = expression muted + result large, right aligned;
tap inserts the result; "Aktuelle Eingabe" label above the live expression; empty state text; clear in the overflow menu
ContextMenu (S04)
floating pill: Ausschneiden, Kopieren, Einfügen, MS, MR (when memory set)
```
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""tokens.json -> Tokens.kt (Compose). Re-run after every token change."""
import json, re, sys, pathlib
src = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "tokens.json")
out = pathlib.Path(sys.argv[2] if len(sys.argv) > 2 else src.with_name("Tokens.kt"))
t = json.loads(src.read_text())
camel = lambda s: re.sub(r"-(\w)", lambda m: m.group(1).upper(), s)
L = ["// Generated from tokens.json by gen_theme.py. Do not edit by hand.",
"package app.rechner.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)
+43
View File
@@ -0,0 +1,43 @@
{
"_about": "Rechner tokens from replica/rechner/screens/ref-01..07 (light, dark, Material You). Fallback palette; on Android 12+ dynamic colour drives the key roles. Check: python3 ~/.claude/skills/replica-design/contrast.py replica/rechner/design/tokens.json",
"color": {
"bg": "#f9f9ff", "display": "#e1e5ee", "sheet": "#e8ebf2",
"text": "#191c20", "text-muted": "#43474e", "result": "#5a5f68",
"key-digit": "#eef0f5", "on-key-digit": "#191c20",
"key-op": "#d6e3f5", "on-key-op": "#0e1d2e",
"key-ac": "#ead9ff", "on-key-ac": "#25113d",
"key-eq": "#cde3fb", "on-key-eq": "#06233d",
"key-fn": "#dde3ec", "on-key-fn": "#191c20",
"error": "#ba1a1a",
"dark-bg": "#0b1414", "dark-display": "#283535", "dark-sheet": "#1e2a2a",
"dark-text": "#e0e6e6", "dark-text-muted": "#bec8c8", "dark-result": "#9fb0b0",
"dark-key-digit": "#172121", "dark-on-key-digit": "#dde4e4",
"dark-key-op": "#a3d1d3", "dark-on-key-op": "#002f31",
"dark-key-ac": "#8fd5e8", "dark-on-key-ac": "#00303b",
"dark-key-eq": "#00e0cc", "dark-on-key-eq": "#00332e",
"dark-key-fn": "#253232", "dark-on-key-fn": "#dde4e4",
"dark-error": "#ffb4ab"
},
"pairs": [
["text","display"],["text-muted","display"],["result","display"],["text","sheet"],["text-muted","sheet"],["text","bg"],
["on-key-digit","key-digit"],["on-key-op","key-op"],["on-key-ac","key-ac"],["on-key-eq","key-eq"],["on-key-fn","key-fn"],["error","display"],
["dark-text","dark-display"],["dark-text-muted","dark-display"],["dark-result","dark-display"],["dark-text","dark-sheet"],["dark-text-muted","dark-sheet"],["dark-text","dark-bg"],
["dark-on-key-digit","dark-key-digit"],["dark-on-key-op","dark-key-op"],["dark-on-key-ac","dark-key-ac"],["dark-on-key-eq","dark-key-eq"],["dark-on-key-fn","dark-key-fn"],["dark-error","dark-display"]
],
"font": {"sans": "Roboto Flex (OFL)", "display": "Roboto Flex, weight 400, tabular figures for expression and result"},
"type": {
"label": {"size": 14, "line": 20, "weight": 500},
"body": {"size": 16, "line": 24, "weight": 400},
"fn": {"size": 22, "line": 28, "weight": 400},
"key": {"size": 34, "line": 40, "weight": 400},
"result": {"size": 36, "line": 44, "weight": 400},
"expr": {"size": 72, "line": 80, "weight": 400},
"expr-min": {"size": 36, "line": 44, "weight": 400},
"history-expr": {"size": 18, "line": 24, "weight": 400},
"history-result": {"size": 28, "line": 34, "weight": 400}
},
"space": [0, 4, 8, 12, 16, 24, 32],
"radius": {"display": 32, "key": 999, "key-pressed": 24, "menu": 24, "pill": 999},
"layout": {"key-gap": 12, "gutter": 16, "keypad-cols": 4, "display-min-height": "35%"},
"motion": {"key-press": "spring dampingRatio 0.6 stiffness 800 (circle -> squircle)", "sheet": "spring 0.9 / 400"}
}
+29
View File
@@ -0,0 +1,29 @@
feature,area,priority,original,clone,notes
Basic arithmetic + − × ÷,core,must,yes,yes,
Live result preview while typing,core,must,yes,yes,
Equals makes the result the new expression,core,must,yes,yes,
Parentheses smart toggle key,core,must,yes,yes,
Percent,core,must,yes,yes,
Backspace and long-press to clear,core,must,yes,yes,
AC clears,core,must,yes,yes,
Exact arithmetic (no floating point errors),core,must,yes,yes,BigDecimal/rational
Errors shown (division by zero domain errors),core,must,yes,yes,
Auto-shrinking display for long input,display,must,yes,yes,
Scientific row √ π ^ !,science,must,yes,yes,
Expanded panel sin cos tan ln log e,science,must,yes,yes,
Deg/Rad switch,science,must,yes,yes,
Inv swaps to inverse functions,science,must,yes,yes,
Implicit multiplication (2π 3(4)),core,should,yes,yes,
History grouped by day,history,must,yes,yes,
Tap a history entry to reuse,history,must,yes,yes,
Clear history,history,should,yes,yes,
Pull down the display to open history,history,should,yes,yes,
Copy result / paste into expression,clipboard,must,yes,yes,
Memory store and recall (MS MR),clipboard,should,yes,yes,
Big numbers like 100! with scrolling,core,should,yes,partial,"exact, shown scientific (9,33E157); full digits not scrollable yet"
Locale decimal separator,display,should,yes,yes,comma in German
Expressive keys with press morph,design,must,yes,yes,
Dynamic colour (wallpaper) and dark mode,design,must,yes,yes,
Haptic key feedback,design,should,yes,yes,
Tablet / landscape layout with all functions,layout,could,yes,no,
Feedback and help,other,could,yes,skip,Google service
1 feature area priority original clone notes
2 Basic arithmetic + − × ÷ core must yes yes
3 Live result preview while typing core must yes yes
4 Equals makes the result the new expression core must yes yes
5 Parentheses smart toggle key core must yes yes
6 Percent core must yes yes
7 Backspace and long-press to clear core must yes yes
8 AC clears core must yes yes
9 Exact arithmetic (no floating point errors) core must yes yes BigDecimal/rational
10 Errors shown (division by zero domain errors) core must yes yes
11 Auto-shrinking display for long input display must yes yes
12 Scientific row √ π ^ ! science must yes yes
13 Expanded panel sin cos tan ln log e science must yes yes
14 Deg/Rad switch science must yes yes
15 Inv swaps to inverse functions science must yes yes
16 Implicit multiplication (2π 3(4)) core should yes yes
17 History grouped by day history must yes yes
18 Tap a history entry to reuse history must yes yes
19 Clear history history should yes yes
20 Pull down the display to open history history should yes yes
21 Copy result / paste into expression clipboard must yes yes
22 Memory store and recall (MS MR) clipboard should yes yes
23 Big numbers like 100! with scrolling core should yes partial exact, shown scientific (9,33E157); full digits not scrollable yet
24 Locale decimal separator display should yes yes comma in German
25 Expressive keys with press morph design must yes yes
26 Dynamic colour (wallpaper) and dark mode design must yes yes
27 Haptic key feedback design should yes yes
28 Tablet / landscape layout with all functions layout could yes no
29 Feedback and help other could yes skip Google service
+78
View File
@@ -0,0 +1,78 @@
# Recon map: Google Calculator / Pixel Calculator (Android)
Scope: the whole app: keypad, scientific functions, live result, history, memory, copy/paste, settings
For: personal use, a calculator without Google services (project NoGooleApps)
Date: 2026-10-17
## Sources
| # | source | URL | notes |
| --- | --- | --- | --- |
| 1 | app store listing | https://play.google.com/store/apps/details?id=com.google.android.calculator | page did not load via fetch; listing images supplied by the user (ref-01..03) |
| 2 | screenshots | replica/rechner/screens/ref-01..07.png | supplied by user: history with "Current expression" sheet and MS/Copy menu (ref-01, -02, -06), scientific panel open (ref-03), launcher icon (ref-04), selection with Cut/Copy/MS (ref-05), empty dark state with Material You colours (ref-07). Reference only, never shipped |
| 3 | general knowledge of the app as a user | – | precise arithmetic (no 0.1+0.2 errors), very long results scroll, Deg/Rad, Inv swaps functions |
## Core loop
Type a calculation and see the result update live, then tap = to make it the new start value.
## Screens
| ID | screen | how to reach | purpose | key components | states seen |
| --- | --- | --- | --- | --- | --- |
| S01 | Calculator | launch | type and evaluate | display (expression + live result, right aligned, auto-shrinking font), scientific row (√ π ^ !) with expand chevron, keypad 4×5 (AC, ( ), %, ÷ / 7-9 × / 4-6 − / 1-3 + / 0 . ⌫ =), history button, overflow | empty (cursor), typing, live result, result after =, error ("Kann nicht durch 0 teilen"), too long (scrolls) (ref-05..07) |
| S02 | Scientific panel | chevron or swipe up on the row | more functions | extra rows: Deg/Rad, sin cos tan, Inv, e ln log, (with Inv: sin⁻¹ cos⁻¹ tan⁻¹, eˣ, 10ˣ, x²) | collapsed, expanded (ref-03) |
| S03 | History | pull the display down / history button | past calculations | list grouped by day ("Heute"), each expression + result, "Current expression" at the bottom of the sheet, tap to reuse, clear history | empty, filled (ref-01, -02, -06) |
| S04 | Context menu | long press on display or a history result | reuse values | Cut, Copy, Paste, MS (memory store), MR when memory set | (ref-01, -05) |
| S05 | Settings / overflow | ⋮ | options | history clear, choose theme, about, (original: send feedback) | |
## Flows
```
F01 Quick sum
S01: 1 2 + 3 4 → live result 46 → =
happy path taps: 6
F02 Scientific
S01 → expand → sin ( Deg … ) → =
F03 Reuse an old result
S01 pull down → S03 → tap a result → it is inserted at the cursor
F04 Copy the result
S01 long press → S04 Copy
F05 Store a value and recall it
long press → MS … later long press → MR
edge: memory survives app restart
edge cases everywhere: division by zero, unbalanced parentheses (auto close), 0.1+0.2 exact, 100! (big), %, implicit multiplication "2π", leading minus, very long input
```
## Components
| component | variants | states | used on |
| --- | --- | --- | --- |
| Key | digit (surface), operator (secondary container), AC (tertiary), equals (primary, fully round), function (text only in the collapsed row / tonal pills when expanded) | default, pressed (shape squishes to a squircle), disabled | S01, S02 |
| Display | expression (large, shrinks with length), result preview (smaller, accent colour), cursor | empty, typing, final, error | S01 |
| Sheet | history over display with drag handle | collapsed, expanded | S03 |
| History row | expression muted, result bold | pressed | S03 |
| Context menu | floating pill menu | | S04 |
| Chevron | expand/collapse | | S02 |
## Inferred data model
```
HistoryEntry id, expression (text), result (text), created_at
evidence: S03 "Today" grouping confidence: high
Memory value (exact number as text) confidence: high (MS/MR)
Settings angle_unit (deg|rad), theme confidence: high
```
## Feature matrix
See `features.csv`.
## Out of scope
- Google's feedback/help integration.
- Google's own number fonts and icons: Roboto Flex and own icon.
## Size
Screens 5, flows 5, entities 3. Hard parts: (1) exact arithmetic with big numbers and correct rounding/display of irrational results, (2) a parser that tolerates partial input for the live result (implicit multiplication, auto-closing parentheses, percent rules), (3) the expressive keypad (shape morph on press, auto-sizing display). Size: S–M.
+9
View File
@@ -0,0 +1,9 @@
-- Local SQLite (Room). No network, no accounts.
CREATE TABLE history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
expression TEXT NOT NULL CHECK (length(expression) > 0),
result TEXT NOT NULL,
created_at INTEGER NOT NULL -- epoch ms UTC; grouped by local day in the UI
);
CREATE INDEX history_created ON history(created_at);
-- Memory value, angle unit, theme and the current (unfinished) expression live in DataStore.
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 927 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

+49
View File
@@ -0,0 +1,49 @@
# Architecture: Telefon (Android, ohne Google)
Same conventions as the other apps. **No network permission.** The app owns almost no data: it reads the system call log and contacts.
## Stack
| Schicht | Wahl | warum |
| --- | --- | --- |
| UI | Kotlin + Compose, M3 Expressive, dynamic colour | same as the others |
| Telefonie | `RoleManager.ROLE_DIALER` + own `InCallService` (BIND_INCALL_SERVICE, IN_CALL_SERVICE_UI) | the only official way to show the call UI and control calls |
| Anrufen | `TelecomManager.placeCall` | uses the default SIM/phone account, works for emergency numbers through the system |
| Daten | `CallLog.Calls` and `ContactsContract` via ContentResolver, observed with ContentObserver → Flow | no copy of personal data in our database |
| Eigene Daten | DataStore (quick replies, theme) | – |
| Audio | `InCallService.setMuted / setAudioRoute`, `CallEndpoint` on API 34+ | speaker, earpiece, Bluetooth |
| Näherungssensor | `PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK` while the earpiece is active | screen off at the ear |
## Permissions
READ_CALL_LOG, WRITE_CALL_LOG (delete entries), READ_CONTACTS, CALL_PHONE, READ_PHONE_STATE, MANAGE_OWN_CALLS no, USE_FULL_SCREEN_INTENT, POST_NOTIFICATIONS, WAKE_LOCK, SEND_SMS no (quick replies hand off to the SMS app with `smsto:`).
The dialer role grants most of these automatically once the user accepts it.
## Data
No schema. Own state in DataStore only. Access: single user; the system enforces the permissions.
## Interne API
| Aufruf | macht | Flow |
| --- | --- | --- |
| `CallLogRepo.observe(filter)` | query CallLog, join contact name/photo by number (PhoneLookup), group by day | F01 |
| `ContactsRepo.favorites()` / `search(q)` / `t9(digits)` | starred contacts; name/number search; T9 digit match on names and numbers | F02, F03, F06 |
| `Dialer.call(number)` | `placeCall(tel:)` | F01–F03 |
| `CallManager` (in `InCallService`) | StateFlow of calls; answer, reject (with SMS), hold, unhold, mute, route, DTMF, disconnect | F04, F05, F07 |
| `Role.request()` | role request intent | F08 |
## Was beißt
- **Wrong default dialer state**: without the role, calls still work through the system dialer; the app shows a banner instead of failing.
- **Lock screen**: incoming UI via full-screen-intent notification; `showWhenLocked` + `turnScreenOn` on the activity.
- **Ringtone**: the system rings for the default dialer? No: the InCallService app must play the ringtone itself only when `Call.Details` say so; on modern Android the platform `Ringer` plays it for the default dialer. We do not play our own.
- **Process death mid-call**: the InCallService is bound by the system as long as calls exist; UI state is rebuilt from `calls`.
- **Emergency numbers**: always via `placeCall`, never blocked by our UI.
- **Privacy**: no network, no analytics; call log stays in the system.
## Build-Reihenfolge
1. Slice: role request, InCallService, incoming + in-call screens, place a call from the keypad.
2. Must: call log with grouping and call back, favourites, search, notifications, mute/speaker/DTMF, proximity.
3. Should: filters, T9, hold, call waiting, quick reply SMS, details screen, voicemail, Bluetooth.
+17
View File
@@ -0,0 +1,17 @@
# Build log: Telefon
App in `telefon/`. Screenshots via Roborazzi with made-up contacts → `replica/telefon/clone-screens/`.
| ID | date | status | missing | notes |
| --- | --- | --- | --- | --- |
| S01 Home | 2026-10-17 | done | delete entries | call log from the system CallLog, merged consecutive calls, favourites = starred contacts |
| S02 Search | 2026-10-17 | done | – | |
| S03 Keypad | 2026-10-17 | done | – | T9 suggestions |
| S04 Voicemail | 2026-10-17 | done | – | dials the SIM's voicemail number |
| S05 Details | 2026-10-17 | done | – | |
| S06 Incoming | 2026-10-17 | done | – | full-screen intent + CallStyle notification; ringtone played by the platform |
| S07 In call | 2026-10-17 | done | add call, merge | mute, speaker, Bluetooth, hold, DTMF, proximity |
| S08 Notifications | 2026-10-17 | done | – | |
| S09 Settings | – | not started | quick reply editing | |
**Not tested on a device yet** (phone was disconnected): role request, a real incoming and outgoing call, lock screen, Bluetooth.
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+71
View File
@@ -0,0 +1,71 @@
// Generated from tokens.json by gen_theme.py. Do not edit by hand.
package app.telefon.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(0xFFF6F7FB)
val card = Color(0xFFFFFFFF)
val text = Color(0xFF191C20)
val textMuted = Color(0xFF44474E)
val missed = Color(0xFFBA1A1A)
val chipOn = Color(0xFFD3E3FD)
val onChipOn = Color(0xFF041E49)
val accept = Color(0xFF1E6B38)
val onAccept = Color(0xFFFFFFFF)
val hangup = Color(0xFFC5221F)
val onHangup = Color(0xFFFFFFFF)
val toggle = Color(0xFFFFFFFF)
val onToggle = Color(0xFF191C20)
val toggleOn = Color(0xFFD3E3FD)
val onToggleOn = Color(0xFF041E49)
val slideTrack = Color(0xFFE2E5EC)
val onSlideTrack = Color(0xFF191C20)
val darkBg = Color(0xFF111318)
val darkCard = Color(0xFF1D2024)
val darkText = Color(0xFFE2E2E9)
val darkTextMuted = Color(0xFFC4C6D0)
val darkMissed = Color(0xFFFFB4AB)
val darkChipOn = Color(0xFF2F4A72)
val darkOnChipOn = Color(0xFFD6E3FF)
val darkAccept = Color(0xFF6FDC8C)
val darkOnAccept = Color(0xFF00391A)
val darkHangup = Color(0xFFF2645A)
val darkOnHangup = Color(0xFF410002)
val darkToggle = Color(0xFF2B2F36)
val darkOnToggle = Color(0xFFE2E2E9)
val darkToggleOn = Color(0xFFA8C7FA)
val darkOnToggleOn = Color(0xFF062E6F)
val darkSlideTrack = Color(0xFF2B2F36)
val darkOnSlideTrack = Color(0xFFE2E2E9)
}
object WType {
val label = TextStyle(fontSize = 12.sp, lineHeight = 16.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 caller = TextStyle(fontSize = 40.sp, lineHeight = 48.sp, fontWeight = FontWeight.W400)
val dial = TextStyle(fontSize = 36.sp, lineHeight = 44.sp, fontWeight = FontWeight.W400)
val key = TextStyle(fontSize = 30.sp, lineHeight = 36.sp, fontWeight = FontWeight.W400)
}
object WSpace {
val s0 = 0.dp
val s4 = 4.dp
val s8 = 8.dp
val s12 = 12.dp
val s16 = 16.dp
val s24 = 24.dp
val s32 = 32.dp
}
object WRadius {
val cardOuter = 24.dp
val cardInner = 4.dp
val pill = 999.dp
}
+7
View File
@@ -0,0 +1,7 @@
# Komponenten: Telefon
Avatars: photo from contacts, else initial on a colour derived from the name (stable), unknown number = person icon. On call screens the avatar is a large scalloped cookie (12 waves).
CallLogGroup: rows inside one card per day, outer radius 24, inner 4, 2dp gaps (same kit as the settings tiles). Row: avatar 40, name (or formatted number), line 2 = direction icon + label/type + time, missed in error colour, trailing call icon button.
SlideToAnswer: track pill, labels "Ablehnen" left / "Annehmen" right, thumb with phone icon; past 50 % acts; custom accessibility actions.
InCallControls: 4 round toggles 64dp with label below (Wähltasten, Stumm, Lautsprecher, Mehr), red hang-up pill 160×64 below; "Mehr" sheet: Halten, Anruf hinzufügen.
Keypad: 3×4 keys (digit 30sp + letters 11sp), green round call FAB 72dp, backspace.
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""tokens.json -> Tokens.kt (Compose). Re-run after every token change."""
import json, re, sys, pathlib
src = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "tokens.json")
out = pathlib.Path(sys.argv[2] if len(sys.argv) > 2 else src.with_name("Tokens.kt"))
t = json.loads(src.read_text())
camel = lambda s: re.sub(r"-(\w)", lambda m: m.group(1).upper(), s)
L = ["// Generated from tokens.json by gen_theme.py. Do not edit by hand.",
"package app.telefon.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)
+26
View File
@@ -0,0 +1,26 @@
{
"_about": "Telefon tokens from replica/telefon/screens/ref-01..03. Fallback palette; dynamic colour on Android 12+. Check with contrast.py.",
"color": {
"bg": "#f6f7fb", "card": "#ffffff", "text": "#191c20", "text-muted": "#44474e", "missed": "#ba1a1a",
"chip-on": "#d3e3fd", "on-chip-on": "#041e49", "accept": "#1e6b38", "on-accept": "#ffffff",
"hangup": "#c5221f", "on-hangup": "#ffffff", "toggle": "#ffffff", "on-toggle": "#191c20", "toggle-on": "#d3e3fd", "on-toggle-on": "#041e49",
"slide-track": "#e2e5ec", "on-slide-track": "#191c20",
"dark-bg": "#111318", "dark-card": "#1d2024", "dark-text": "#e2e2e9", "dark-text-muted": "#c4c6d0", "dark-missed": "#ffb4ab",
"dark-chip-on": "#2f4a72", "dark-on-chip-on": "#d6e3ff", "dark-accept": "#6fdc8c", "dark-on-accept": "#00391a",
"dark-hangup": "#f2645a", "dark-on-hangup": "#410002", "dark-toggle": "#2b2f36", "dark-on-toggle": "#e2e2e9", "dark-toggle-on": "#a8c7fa", "dark-on-toggle-on": "#062e6f",
"dark-slide-track": "#2b2f36", "dark-on-slide-track": "#e2e2e9"
},
"pairs": [
["text","bg"],["text","card"],["text-muted","card"],["missed","card"],["on-chip-on","chip-on"],["on-accept","accept"],["on-hangup","hangup"],
["on-toggle","toggle"],["on-toggle-on","toggle-on"],["on-slide-track","slide-track"],
["dark-text","dark-bg"],["dark-text","dark-card"],["dark-text-muted","dark-card"],["dark-missed","dark-card"],["dark-on-chip-on","dark-chip-on"],
["dark-on-accept","dark-accept"],["dark-on-hangup","dark-hangup"],["dark-on-toggle","dark-toggle"],["dark-on-toggle-on","dark-toggle-on"],["dark-on-slide-track","dark-slide-track"]
],
"type": {
"label": {"size": 12, "line": 16, "weight": 500}, "body-sm": {"size": 14, "line": 20, "weight": 400}, "body": {"size": 16, "line": 24, "weight": 400},
"title": {"size": 22, "line": 28, "weight": 400}, "caller": {"size": 40, "line": 48, "weight": 400}, "dial": {"size": 36, "line": 44, "weight": 400}, "key": {"size": 30, "line": 36, "weight": 400}
},
"space": [0, 4, 8, 12, 16, 24, 32],
"radius": {"card-outer": 24, "card-inner": 4, "pill": 999},
"layout": {"avatar-row": 56, "avatar-call": 200}
}
+34
View File
@@ -0,0 +1,34 @@
feature,area,priority,original,clone,notes
Become the default phone app (role request),core,must,yes,yes,
Call log grouped by day with direction icons,home,must,yes,yes,
Call back from the log in one tap,home,must,yes,yes,
Missed calls highlighted,home,must,yes,yes,
Filter chips All Missed Contacts,home,should,yes,yes,
Favourites row (starred contacts),home,must,yes,yes,
Search contacts and numbers,search,must,yes,yes,
Keypad with letters,keypad,must,yes,yes,
T9 contact suggestions on the keypad,keypad,should,yes,yes,
Place a call,call,must,yes,yes,
Incoming call full screen with slide answer/decline,call,must,yes,yes,
Incoming call notification with actions,call,must,yes,yes,
In-call screen with timer,call,must,yes,yes,
Mute,call,must,yes,yes,
Speaker,call,must,yes,yes,
In-call keypad (DTMF),call,must,yes,yes,
Hold,call,should,yes,yes,
Second incoming call (call waiting),call,should,yes,partial,the incoming screen appears over the active call; merging/swapping UI missing
Ongoing call notification,call,must,yes,yes,
Proximity sensor screen off at the ear,call,must,yes,yes,
Decline with quick reply SMS,call,should,yes,yes,
Contact and number details with history,details,should,yes,yes,
Toggle favourite,details,could,yes,yes,
Open contact in contacts app / add contact,details,should,yes,yes,
Call voicemail,voicemail,should,yes,yes,
Delete call log entries,home,could,yes,no,
Expressive design (scalloped avatars grouped cards slide pill),design,must,yes,yes,
Dynamic colour and dark mode,design,must,yes,yes,
Bluetooth audio route,call,should,yes,yes,
Spam detection and caller ID,home,could,yes,skip,Google database
Call screening and hold for me,call,could,yes,skip,Google AI
Call recording and live translation,call,could,yes,skip,Google AI; recording blocked for apps
Visual voicemail transcription,voicemail,could,yes,skip,carrier/Google
1 feature area priority original clone notes
2 Become the default phone app (role request) core must yes yes
3 Call log grouped by day with direction icons home must yes yes
4 Call back from the log in one tap home must yes yes
5 Missed calls highlighted home must yes yes
6 Filter chips All Missed Contacts home should yes yes
7 Favourites row (starred contacts) home must yes yes
8 Search contacts and numbers search must yes yes
9 Keypad with letters keypad must yes yes
10 T9 contact suggestions on the keypad keypad should yes yes
11 Place a call call must yes yes
12 Incoming call full screen with slide answer/decline call must yes yes
13 Incoming call notification with actions call must yes yes
14 In-call screen with timer call must yes yes
15 Mute call must yes yes
16 Speaker call must yes yes
17 In-call keypad (DTMF) call must yes yes
18 Hold call should yes yes
19 Second incoming call (call waiting) call should yes partial the incoming screen appears over the active call; merging/swapping UI missing
20 Ongoing call notification call must yes yes
21 Proximity sensor screen off at the ear call must yes yes
22 Decline with quick reply SMS call should yes yes
23 Contact and number details with history details should yes yes
24 Toggle favourite details could yes yes
25 Open contact in contacts app / add contact details should yes yes
26 Call voicemail voicemail should yes yes
27 Delete call log entries home could yes no
28 Expressive design (scalloped avatars grouped cards slide pill) design must yes yes
29 Dynamic colour and dark mode design must yes yes
30 Bluetooth audio route call should yes yes
31 Spam detection and caller ID home could yes skip Google database
32 Call screening and hold for me call could yes skip Google AI
33 Call recording and live translation call could yes skip Google AI; recording blocked for apps
34 Visual voicemail transcription voicemail could yes skip carrier/Google
+98
View File
@@ -0,0 +1,98 @@
# Recon map: Google Phone / Pixel Phone (Android)
Scope: home (search, filter chips, favourites, call log), keypad with contact search, voicemail tab, contact/number details, incoming call, in-call screen, call notifications
For: personal use, a dialer without Google services (project NoGooleApps)
Date: 2026-10-17
## Sources
| # | source | URL | notes |
| --- | --- | --- | --- |
| 1 | screenshots | replica/telefon/screens/ref-01..03.png | supplied by user: home/incoming/in-call (Material 3 Expressive, 2025), older dark in-call with keypad and "More" (hold, video, add call), German call-assist sheet (recording, translation). Reference only |
| 2 | platform docs | https://developer.android.com/reference/android/telecom/InCallService | how a default dialer receives and controls calls |
| 3 | platform docs | https://developer.android.com/reference/android/app/role/RoleManager#ROLE_DIALER | default phone app role |
| 4 | general knowledge as a user | – | filters, favourites, call history grouping, swipe pill answer/decline |
## Core loop
See who called and call back in one tap; answer an incoming call reliably, even on the lock screen.
## Screens
| ID | screen | how to reach | purpose | key components | states seen |
| --- | --- | --- | --- | --- | --- |
| S01 | Home | launch, tab "Start" | favourites + recent calls | search bar with menu, filter chips (Alle, Verpasst, Kontakte), favourites row (round avatars, "Alle ansehen"), call log grouped by day (avatar/initial, name or number, direction icon + type + time, call button), bottom nav | empty, no permission, filled, missed in red (ref-01) |
| S02 | Search | tap the search bar | find contacts and numbers | text field, contact results, "call number" row | empty, results |
| S03 | Keypad | tab "Wähltasten" | dial a number | number field, 12 keys with letters, call FAB, backspace (long = clear), T9 contact suggestions | empty, typing, matches |
| S04 | Voicemail | tab "Mailbox" | reach voicemail | call voicemail button, number from SIM | no number, number set |
| S05 | Contact / number details | tap avatar or row | everything about a person | big avatar, name, numbers with call/SMS, recent calls with that number, favourite star, open in contacts app | contact, unknown number |
| S06 | Incoming call | system, full screen | answer or decline | caller name, number + label, big scalloped avatar, quick actions (Nachricht), slide pill Ablehnen ← ☎ → Annehmen | ringing, locked screen (ref-01) |
| S07 | In call | after answer / outgoing | control the call | status (Wird angerufen…, timer), name, number, avatar, buttons Wähltasten, Stumm, Lautsprecher, Mehr (Halten, Anruf hinzufügen), red hang-up pill; inline DTMF keypad | dialing, active, on hold, ended (ref-01, ref-02) |
| S08 | Call notification | heads-up / ongoing | answer from anywhere, return to call | incoming (Annehmen/Ablehnen), ongoing (Auflegen, timer) | |
| S09 | Settings | menu | options | default app status, quick replies, sounds and vibration (system), blocked numbers (system) | |
## Flows
```
F01 Call back a missed call
S01 → tap call icon on the row → S07
taps: 1
F02 Call a favourite
S01 → tap avatar → S07 (taps: 1)
F03 Dial a number
S03 → digits → call FAB → S07
F04 Answer
S06 slide to "Annehmen" → S07 → hang up
edge: locked phone, second incoming call while in a call, caller hangs up while ringing
F05 Decline with a message
S06 → Nachricht → pick a quick reply → SMS app
F06 Find a contact
S01 search → S02 → tap → S05 → call
F07 Use the keypad during a call (menus, PIN)
S07 → Wähltasten → DTMF
F08 Become the default phone app (first run)
S01 → banner → system role dialog → yes
```
## Components
| component | variants | states | used on |
| --- | --- | --- | --- |
| Search bar | pill with menu + search icon | idle, focused | S01, S02 |
| Filter chip | selected, unselected | | S01 |
| Avatar | photo, initial on colour, unknown (person icon), business (building icon); sizes 48 / 120 / 200 scalloped | | all |
| Call log row | grouped card (large outer, small inner radius), direction icon (incoming, outgoing, missed red), call button | pressed | S01 |
| Dial key | digit + letters | pressed | S03, S07 |
| Call button | green round FAB (keypad), red pill (hang up) | | S03, S07 |
| Slide pill | Ablehnen ← thumb → Annehmen | idle, dragging | S06 |
| Toggle button | round tonal, on = filled | mute, speaker, hold | S07 |
| Bottom nav | Start, Wähltasten, Mailbox | | S01, S03, S04 |
## Inferred data model
The phone app owns almost no data: call history is the system `CallLog`, people are the system `ContactsContract`, voicemail number comes from the SIM.
```
CallEntry (system CallLog) number, name, type (incoming|outgoing|missed|rejected|blocked|voicemail), date, duration, numberLabel
confidence: high
Contact (system Contacts) id, lookupKey, name, photoUri, starred, phones[label, number]
confidence: high
QuickReply own: id, text, position (default set, editable) confidence: high
Settings own: theme confidence: high
```
## Feature matrix
See `features.csv`.
## Out of scope
- Spam detection, caller ID from Google's database, "Non-Spam/Spam" filters: Google service.
- Call screening, hold for me, direct my call, call recording, live translation (ref-03): Google AI services; recording is also blocked for normal apps since Android 10.
- Visual voicemail transcription: carrier/Google service; we only dial voicemail.
- Wi-Fi calling, RTT, video calls: carrier/IMS features the system handles.
- Editing contacts: done by a contacts app; we open it.
## Size
Screens 9, flows 8, entities 2 own. Hard parts: (1) becoming the default dialer and implementing an `InCallService` correctly (call states, audio routing, hold, multiple calls), (2) the incoming call UI over the lock screen with full-screen intent and ringtone handled by the system, (3) fast call log + contacts merging and T9 search. Size: M.
Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

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

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

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

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