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
137 changed files with 5655 additions and 105 deletions
+78 -2
View File
@@ -8,6 +8,8 @@
| 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 |
@@ -43,6 +45,42 @@ A weather app with the look and feel of Pixel Weather: an animated sky that chan
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.
@@ -51,7 +89,8 @@ A clock and alarm app with the look and flow of the Pixel clock: alarms in expan
- **Ringing done right:** a full-screen screen over the lock screen with slide to snooze or stop, alarm-stream audio that gets louder gradually, vibration, automatic silence after a set time, and notifications for upcoming, snoozed and missed alarms. Volume keys can snooze or stop.
- **No network permission at all.** World clock cities come from the system time zone database (names localised offline), sounds from the device, the app's own tone, or your own audio files.
- **Also:** days and labels per alarm, pause until a date, skip the next ring, timers with ring progress and +1:00 that keep running in the background with a live countdown notification, stopwatch with fastest/slowest laps, analog or digital clock, wallpaper colours (Material You), three home-screen widgets (live digital clock, analog clock, next alarm).
- **Coming next:** widget style options like Wetter, screensaver/night clock, sunrise alarm, sleep sounds, reordering world clocks.
- **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">
@@ -68,6 +107,17 @@ A clock and alarm app with the look and flow of the Pixel clock: alarms in expan
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
[Obtainium](https://github.com/ImranR98/Obtainium) installs and updates apps straight from this repository's releases, with no store in between.
@@ -101,6 +151,26 @@ Same setup as Wetter, with the filters `^Uhr ` (release titles) and `^uhr-.*\.ap
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.
**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).
@@ -119,6 +189,10 @@ Every app is rebuilt with **[Replica](https://github.com/Jakeschincariol/replica
| 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 |
**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:
@@ -142,7 +216,7 @@ Each commit says what changed and carries a `Co-Authored-By` line for the AI.
Requirements: JDK 17 or newer and the Android SDK (platform 36).
```bash
cd wetter # or: cd uhr
cd wetter # or: cd uhr, cd rechner
./gradlew :app:assembleRelease # APK in app/build/outputs/apk/release/
./gradlew :app:testDebugUnitTest # unit + screenshot tests
./gradlew :app:recordRoborazziDebug # re-render replica/wetter/clone-screens/
@@ -155,6 +229,8 @@ Release builds are signed with the project key from `~/.android/nogoogleapps-rel
```
replica/<app>/ recon, architecture, design system, build log, screenshots (one folder per app)
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)
```
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

+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

+3 -3
View File
@@ -11,11 +11,11 @@ Device checks on a Pixel 10 Pro XL (Android 16) via a debug-only activity that s
| S03 Time picker | 2026-10-17 | done | – | – |
| S04 Sound picker | 2026-10-17 | done | – | own file not device-tested |
| S05 Pause alarm | 2026-10-17 | done | – | – |
| S06 Clock | 2026-10-17 | done | reorder cities | city names offline via ICU exemplar cities |
| 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 | label edit UI | verified on device: 5 s timer rang in the background, stopped from the notification |
| 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 | partial | sunrise alarm, sleep sounds | – |
| 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 | – |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 54 KiB

+4
View File
@@ -65,6 +65,10 @@ object WColor {
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 {
+311 -52
View File
@@ -1,45 +1,230 @@
{
"_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",
"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",
"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"
"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"]
[
"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",
@@ -48,21 +233,88 @@
"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}
"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
},
"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",
@@ -72,7 +324,14 @@
"motion": {
"card-expand": "spring dampingRatio 0.85 stiffness 380",
"slide-act": "spring back dampingRatio 0.7",
"fast": "120ms", "base": "250ms"
"fast": "120ms",
"base": "250ms"
},
"layout": {"gutter": 16, "card-gap": 8, "nav-height": 80, "fab-bottom": 16, "card-padding": 20}
}
"layout": {
"gutter": 16,
"card-gap": 8,
"nav-height": 80,
"fab-bottom": 16,
"card-padding": 20
}
}
+4 -4
View File
@@ -24,14 +24,14 @@ Missed alarm notification,notifications,should,yes,yes,
Snoozed notification with dismiss,notifications,must,yes,yes,
World clock: local time and date,clock,must,yes,yes,
World clock: add city via search,clock,must,yes,yes,
World clock: reorder and swipe to delete,clock,should,yes,partial,swipe to delete only
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,partial,stored and shown; no UI to edit yet
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
@@ -40,8 +40,8 @@ Stopwatch laps,stopwatch,must,yes,yes,
Stopwatch running notification,stopwatch,should,yes,yes,
Bedtime schedule with wake-up alarm,bedtime,should,yes,yes,
Bedtime reminder notification,bedtime,should,yes,yes,
Sunrise alarm (brightening screen),bedtime,could,yes,no,
Sleep sounds with timer,bedtime,could,yes,no,own CC0 sounds
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,
1 feature area priority original clone notes
24 Snoozed notification with dismiss notifications must yes yes
25 World clock: local time and date clock must yes yes
26 World clock: add city via search clock must yes yes
27 World clock: reorder and swipe to delete clock should yes partial yes swipe to delete only 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 partial yes stored and shown; no UI to edit yet 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
40 Stopwatch running notification stopwatch should yes yes
41 Bedtime schedule with wake-up alarm bedtime should yes yes
42 Bedtime reminder notification bedtime should yes yes
43 Sunrise alarm (brightening screen) bedtime could yes no 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 no yes own CC0 sounds 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
+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.telefon"
compileSdk = 36
defaultConfig {
applicationId = "app.telefon"
minSdk = 29
targetSdk = 36
versionCode = 3
versionName = "0.1.2"
}
// Release key lives outside the repo; without it, release builds fall back to the debug key (local only).
val keyProps = Properties().apply {
val f = file(System.getProperty("user.home") + "/.android/nogoogleapps-release.properties")
if (f.exists()) f.inputStream().use { load(it) }
}
signingConfigs {
if (keyProps.isNotEmpty()) create("release") {
storeFile = file(keyProps.getProperty("storeFile"))
storePassword = keyProps.getProperty("storePassword")
keyAlias = keyProps.getProperty("keyAlias")
keyPassword = keyProps.getProperty("keyPassword")
}
}
buildTypes {
debug {
// installs next to the release app from Obtainium
applicationIdSuffix = ".debug"
resValue("string", "app_name", "Telefon (Debug)")
}
release {
resValue("string", "app_name", "Telefon")
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/telefon/clone-screens")) }
View File
+64
View File
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- no INTERNET: calls go through the system, nothing leaves the device from this app -->
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.WRITE_CALL_LOG" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-feature android:name="android.hardware.telephony" android:required="false" />
<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:launchMode="singleTask" android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- required for the default phone app role -->
<intent-filter>
<action android:name="android.intent.action.DIAL" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.DIAL" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="tel" />
</intent-filter>
</activity>
<activity
android:name=".call.CallActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleInstance"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:taskAffinity="" />
<service
android:name=".call.CallService"
android:exported="true"
android:permission="android.permission.BIND_INCALL_SERVICE">
<meta-data android:name="android.telecom.IN_CALL_SERVICE_UI" android:value="true" />
<intent-filter><action android:name="android.telecom.InCallService" /></intent-filter>
</service>
<receiver android:name=".call.CallActionReceiver" android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,205 @@
package app.telefon
import android.Manifest
import android.app.role.RoleManager
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.provider.ContactsContract
import android.telecom.TelecomManager
import android.telephony.TelephonyManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.outlined.Dialpad
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Voicemail
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import app.telefon.call.CallService
import app.telefon.data.Contacts
import app.telefon.data.Person
import app.telefon.ui.screens.DetailsScreen
import app.telefon.ui.screens.HomeScreen
import app.telefon.ui.screens.KeypadScreen
import app.telefon.ui.screens.SearchScreen
import app.telefon.ui.screens.VoicemailScreen
import app.telefon.ui.theme.PhoneTheme
import app.telefon.ui.theme.WType
/** Also handles DIAL/VIEW tel: links from other apps (required to be the default phone app). */
class MainActivity : ComponentActivity() {
private val dialNumber = mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
CallService.channels(this)
handle(intent)
setContent { PhoneTheme { PhoneApp(dialNumber.value) { dialNumber.value = null } } }
}
override fun onNewIntent(intent: Intent) { super.onNewIntent(intent); handle(intent) }
private fun handle(i: Intent?) {
if (i?.action == Intent.ACTION_DIAL || i?.action == Intent.ACTION_VIEW) {
dialNumber.value = i.data?.takeIf { it.scheme == "tel" }?.schemeSpecificPart ?: ""
}
}
}
private sealed interface Screen {
data object Search : Screen
data class Details(val person: Person?, val number: String?) : Screen
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PhoneApp(dialRequest: String?, onDialHandled: () -> Unit) {
val ctx = LocalContext.current
var tab by rememberSaveable { mutableIntStateOf(0) }
var screen by remember { mutableStateOf<Screen?>(null) }
var dialInitial by remember { mutableStateOf("") }
LaunchedEffect(dialRequest) { if (dialRequest != null) { dialInitial = dialRequest; tab = 1; screen = null; onDialHandled() } }
var granted by remember { mutableStateOf(hasCore(ctx)) }
val perms = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted = hasCore(ctx) }
val role = ctx.getSystemService(RoleManager::class.java)
var isDefault by remember { mutableStateOf(role.isRoleHeld(RoleManager.ROLE_DIALER)) }
val roleLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
isDefault = role.isRoleHeld(RoleManager.ROLE_DIALER); granted = hasCore(ctx)
}
var askedRole by rememberSaveable { mutableStateOf(false) }
// first start: ask for the role right away; the dialog grants the phone permissions too
LaunchedEffect(Unit) {
if (!isDefault && !askedRole && role.isRoleAvailable(RoleManager.ROLE_DIALER)) {
askedRole = true
roleLauncher.launch(role.createRequestRoleIntent(RoleManager.ROLE_DIALER))
return@LaunchedEffect
}
if (!granted) perms.launch(arrayOf(Manifest.permission.READ_CALL_LOG, Manifest.permission.READ_CONTACTS, Manifest.permission.CALL_PHONE,
Manifest.permission.READ_PHONE_STATE, Manifest.permission.POST_NOTIFICATIONS))
}
val log by remember(granted) { Contacts.callLog(ctx) }.collectAsState(initial = emptyList())
val people by remember(granted) { Contacts.people(ctx) }.collectAsState(initial = emptyList())
var pendingNumber by remember { mutableStateOf<String?>(null) }
// without the role Android shows the call in the system phone app: say so instead of jumping away
val call: (String) -> Unit = { n -> if (isDefault) placeCall(ctx, n) else pendingNumber = n }
pendingNumber?.let { n ->
androidx.compose.material3.AlertDialog(
onDismissRequest = { pendingNumber = null },
title = { Text(stringResource(R.string.default_title)) },
text = { Text(stringResource(R.string.not_default_call)) },
confirmButton = {
androidx.compose.material3.TextButton(onClick = { pendingNumber = null; roleLauncher.launch(role.createRequestRoleIntent(RoleManager.ROLE_DIALER)) }) {
Text(stringResource(R.string.make_default))
}
},
dismissButton = { androidx.compose.material3.TextButton(onClick = { pendingNumber = null; placeCall(ctx, n) }) { Text(stringResource(R.string.call_anyway)) } },
)
}
screen?.let { s ->
BackHandler { screen = null }
Scaffold(topBar = {
TopAppBar(title = { if (s is Screen.Search) Text(stringResource(R.string.search)) }, navigationIcon = {
IconButton(onClick = { screen = null }) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.back)) }
})
}) { pad ->
when (s) {
Screen.Search -> SearchScreen(people, pad, call) { screen = Screen.Details(it, null) }
is Screen.Details -> {
val p = s.person?.let { sp -> people.firstOrNull { it.id == sp.id } ?: sp }
DetailsScreen(p, s.number, log, pad, call,
onSms = { n -> ctx.startActivity(Intent(Intent.ACTION_SENDTO, Uri.parse("smsto:$n"))) },
onStar = { Contacts.setStarred(ctx, it.id, !it.starred) },
onAddContact = { n -> ctx.startActivity(Intent(Intent.ACTION_INSERT_OR_EDIT).setType(ContactsContract.Contacts.CONTENT_ITEM_TYPE).putExtra(ContactsContract.Intents.Insert.PHONE, n)) },
onOpenContacts = { pp -> ctx.startActivity(Intent(Intent.ACTION_VIEW, ContactsContract.Contacts.getLookupUri(pp.id, pp.lookupKey))) })
}
}
}
return
}
Scaffold(
bottomBar = {
NavigationBar {
listOf(Triple(R.string.tab_home, Icons.Outlined.Home, 0), Triple(R.string.tab_keypad, Icons.Outlined.Dialpad, 1), Triple(R.string.tab_voicemail, Icons.Outlined.Voicemail, 2)).forEach { (t, ic, i) ->
NavigationBarItem(tab == i, { tab = i }, { Icon(ic, null) }, label = { Text(stringResource(t)) })
}
}
},
) { pad ->
Column {
if (!isDefault) {
Column(
Modifier.padding(start = 16.dp, end = 16.dp, top = pad.calculateTopPadding() + 8.dp).clip(RoundedCornerShape(24.dp))
.background(MaterialTheme.colorScheme.secondaryContainer)
.clickable { roleLauncher.launch(role.createRequestRoleIntent(RoleManager.ROLE_DIALER)) }.padding(16.dp),
) {
Text(stringResource(R.string.default_title), style = WType.body, color = MaterialTheme.colorScheme.onSecondaryContainer)
Text(stringResource(R.string.default_text), style = WType.bodySm, color = MaterialTheme.colorScheme.onSecondaryContainer)
}
}
val inner = if (isDefault) pad else androidx.compose.foundation.layout.PaddingValues(bottom = pad.calculateBottomPadding())
when (tab) {
0 -> HomeScreen(log, people, inner, call, onOpenNumber = { n ->
screen = Screen.Details(people.firstOrNull { p -> p.phones.any { android.telephony.PhoneNumberUtils.compare(it.number, n) } }, n)
}, onOpenPerson = { screen = Screen.Details(it, null) }, onSearch = { screen = Screen.Search })
1 -> KeypadScreen(people, inner, dialInitial, call)
2 -> VoicemailScreen(voicemailNumber(ctx), inner, call)
}
}
}
}
private fun hasCore(ctx: android.content.Context) = listOf(Manifest.permission.READ_CALL_LOG, Manifest.permission.READ_CONTACTS, Manifest.permission.CALL_PHONE)
.all { ContextCompat.checkSelfPermission(ctx, it) == PackageManager.PERMISSION_GRANTED }
/** Through Telecom, so SIM choice and emergency numbers are handled by the system. */
private fun placeCall(ctx: android.content.Context, number: String) {
val uri = Uri.fromParts("tel", number, null)
if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CALL_PHONE) == PackageManager.PERMISSION_GRANTED) {
runCatching { ctx.getSystemService(TelecomManager::class.java).placeCall(uri, Bundle()) }
.onFailure { ctx.startActivity(Intent(Intent.ACTION_DIAL, uri)) }
} else ctx.startActivity(Intent(Intent.ACTION_DIAL, uri))
}
@android.annotation.SuppressLint("MissingPermission")
private fun voicemailNumber(ctx: android.content.Context): String? = runCatching {
ctx.getSystemService(TelephonyManager::class.java).voiceMailNumber?.takeIf { it.isNotBlank() }
}.getOrNull()
@@ -0,0 +1,248 @@
package app.telefon.call
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.telecom.CallAudioState
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
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.VolumeUp
import androidx.compose.material.icons.rounded.Bluetooth
import androidx.compose.material.icons.rounded.CallEnd
import androidx.compose.material.icons.rounded.Dialpad
import androidx.compose.material.icons.rounded.MicOff
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Pause
import androidx.compose.material.icons.rounded.Phone
import androidx.compose.material.icons.outlined.Sms
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import app.telefon.R
import app.telefon.data.Contacts
import app.telefon.ui.components.Avatar
import app.telefon.ui.components.Fmt
import app.telefon.ui.screens.DialPad
import app.telefon.ui.theme.LocalCallColors
import app.telefon.ui.theme.PhoneTheme
import app.telefon.ui.theme.WType
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/** Incoming and in-call screen; shown over the lock screen. */
class CallActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setShowWhenLocked(true)
setTurnScreenOn(true)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
enableEdgeToEdge()
setContent {
val calls by CallManager.state.collectAsState()
val audio by CallManager.audio.collectAsState()
LaunchedEffect(calls) { if (calls.isEmpty() || calls.all { it.phase == Phase.DISCONNECTED }) { delay(800); if (CallManager.state.value.none { it.phase != Phase.DISCONNECTED }) finish() } }
PhoneTheme {
val ringing = calls.firstOrNull { it.phase == Phase.RINGING }
val main = calls.firstOrNull { it.phase != Phase.RINGING } ?: ringing
when {
main == null -> Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface))
// a second call while talking: answer replaces holding the first, which the system does for us
ringing != null -> IncomingScreen(ringing, onAnswer = { CallManager.answer(ringing.id) }, onDecline = { CallManager.reject(ringing.id) },
onMessage = { text -> CallManager.reject(ringing.id, text) })
else -> InCallScreen(main, audio, calls.count { it.phase != Phase.DISCONNECTED })
}
}
}
}
companion object {
fun intent(context: Context) = Intent(context, CallActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
fun pending(context: Context): PendingIntent = PendingIntent.getActivity(context, 0, intent(context), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
}
}
@Composable
private fun CallerHeader(call: CallUi, status: String, avatarSize: Int) {
val ctx = LocalContext.current
val who = remember(call.number) { Contacts.lookup(ctx, call.number) }
Column(Modifier.fillMaxWidth().statusBarsPadding().padding(top = 48.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(status, style = WType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(8.dp))
Text(who?.first ?: Fmt.number(call.number).ifEmpty { stringResource(R.string.unknown) }, style = WType.caller, color = MaterialTheme.colorScheme.onSurface)
if (who != null) Text(Fmt.number(call.number), style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(32.dp))
Avatar(who?.first, who?.second, avatarSize.dp, cookie = true)
}
}
@Composable
fun IncomingScreen(call: CallUi, onAnswer: () -> Unit, onDecline: () -> Unit, onMessage: (String) -> Unit) {
var replies by remember { mutableStateOf(false) }
val quick = listOf(R.string.reply_1, R.string.reply_2, R.string.reply_3).map { stringResource(it) }
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).navigationBarsPadding(), horizontalAlignment = Alignment.CenterHorizontally) {
CallerHeader(call, stringResource(R.string.incoming_call), 200)
Spacer(Modifier.weight(1f))
Box {
Row(Modifier.clip(RoundedCornerShape(50)).background(MaterialTheme.colorScheme.surfaceContainerLowest).clickable { replies = true }.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Outlined.Sms, null, Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.message), style = WType.bodySm)
}
DropdownMenu(replies, { replies = false }) { quick.forEach { q -> DropdownMenuItem(text = { Text(q) }, onClick = { replies = false; onMessage(q) }) } }
}
Spacer(Modifier.height(32.dp))
SlideToAnswer(onAnswer, onDecline)
Spacer(Modifier.height(40.dp))
}
}
/** Ablehnen ← ☎ → Annehmen */
@Composable
fun SlideToAnswer(onAnswer: () -> Unit, onDecline: () -> Unit) {
val scope = rememberCoroutineScope()
val x = remember { Animatable(0f) }
val decline = stringResource(R.string.decline); val answer = stringResource(R.string.answer)
BoxWithConstraints(
Modifier.padding(horizontal = 24.dp).fillMaxWidth().height(76.dp).clip(RoundedCornerShape(38.dp)).background(MaterialTheme.colorScheme.surfaceContainer)
.semantics { customActions = listOf(CustomAccessibilityAction(answer) { onAnswer(); true }, CustomAccessibilityAction(decline) { onDecline(); true }) },
) {
val max = with(LocalDensity.current) { (maxWidth / 2 - 44.dp).toPx() }
Row(Modifier.fillMaxSize().padding(horizontal = 24.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(decline, style = WType.body, modifier = Modifier.clickable(onClick = onDecline))
Text(answer, style = WType.body, modifier = Modifier.clickable(onClick = onAnswer))
}
Box(
Modifier.align(Alignment.Center).offset { IntOffset(x.value.roundToInt(), 0) }.size(88.dp, 64.dp).clip(RoundedCornerShape(32.dp))
.background(MaterialTheme.colorScheme.surfaceContainerLowest)
.pointerInput(max) {
detectHorizontalDragGestures(onDragEnd = {
scope.launch {
when { x.value > max * .5f -> { x.snapTo(max); onAnswer() }; x.value < -max * .5f -> { x.snapTo(-max); onDecline() }; else -> x.animateTo(0f, spring(.6f)) }
}
}) { _, d -> scope.launch { x.snapTo((x.value + d).coerceIn(-max, max)) } }
},
contentAlignment = Alignment.Center,
) { Icon(Icons.Rounded.Phone, null, tint = LocalCallColors.current.accept) }
}
}
@Composable
fun InCallScreen(call: CallUi, audio: AudioUi, count: Int, nowOverride: Long? = null) {
val now = remember { mutableLongStateOf(nowOverride ?: System.currentTimeMillis()) }
if (nowOverride == null) LaunchedEffect(Unit) { while (true) { now.longValue = System.currentTimeMillis(); delay(1000) } }
var keypad by remember { mutableStateOf(false) }
var more by remember { mutableStateOf(false) }
var dtmf by remember { mutableStateOf("") }
val status = when (call.phase) {
Phase.DIALING -> stringResource(R.string.calling)
Phase.HOLDING -> stringResource(R.string.on_hold)
Phase.DISCONNECTED -> stringResource(R.string.call_ended)
else -> call.connectedAt?.let { Fmt.duration(now.longValue - it) } ?: ""
}
val cc = LocalCallColors.current
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).navigationBarsPadding(), horizontalAlignment = Alignment.CenterHorizontally) {
if (keypad) {
Column(Modifier.statusBarsPadding().padding(top = 24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(dtmf.ifEmpty { " " }, style = WType.dial)
Text(status, style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(Modifier.weight(1f))
DialPad { c -> dtmf += c; CallManager.dtmf(call.id, c) }
} else {
CallerHeader(call, status, 140)
Spacer(Modifier.weight(1f))
}
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp)).background(MaterialTheme.colorScheme.surfaceContainerLow).padding(vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally) {
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), horizontalArrangement = Arrangement.SpaceEvenly) {
Toggle(Icons.Rounded.Dialpad, stringResource(R.string.keypad), keypad) { keypad = !keypad }
Toggle(Icons.Rounded.MicOff, stringResource(R.string.mute), audio.muted) { CallManager.setMuted(!audio.muted) }
val bt = audio.supported and CallAudioState.ROUTE_BLUETOOTH != 0
val speaker = audio.route == CallAudioState.ROUTE_SPEAKER
Toggle(Icons.AutoMirrored.Rounded.VolumeUp, stringResource(R.string.speaker), speaker) {
CallManager.setRoute(if (speaker) CallAudioState.ROUTE_WIRED_OR_EARPIECE else CallAudioState.ROUTE_SPEAKER)
}
Box {
Toggle(Icons.Rounded.MoreVert, stringResource(R.string.more), more) { more = true }
DropdownMenu(more, { more = false }) {
if (call.canHold) DropdownMenuItem(
text = { Text(stringResource(if (call.phase == Phase.HOLDING) R.string.resume else R.string.hold)) },
leadingIcon = { Icon(Icons.Rounded.Pause, null) },
onClick = { more = false; CallManager.hold(call.id, call.phase != Phase.HOLDING) },
)
if (bt) DropdownMenuItem(text = { Text(stringResource(R.string.bluetooth)) }, leadingIcon = { Icon(Icons.Rounded.Bluetooth, null) },
onClick = { more = false; CallManager.setRoute(CallAudioState.ROUTE_BLUETOOTH) })
}
}
}
Spacer(Modifier.height(24.dp))
Box(Modifier.size(160.dp, 64.dp).clip(RoundedCornerShape(32.dp)).background(cc.hangup).clickable { CallManager.hangUp(call.id) }, contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.CallEnd, stringResource(R.string.hang_up), tint = cc.onHangup, modifier = Modifier.size(32.dp))
}
}
}
}
@Composable
private fun Toggle(icon: ImageVector, label: String, on: Boolean, onClick: () -> Unit) {
val s = MaterialTheme.colorScheme
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(76.dp)) {
Box(Modifier.size(64.dp).clip(CircleShape).background(if (on) s.primary else s.surfaceContainerLowest).clickable(onClick = onClick), contentAlignment = Alignment.Center) {
Icon(icon, label, tint = if (on) s.onPrimary else s.onSurface)
}
Spacer(Modifier.height(6.dp))
Text(label, style = WType.label, color = s.onSurfaceVariant, maxLines = 1)
}
}
@@ -0,0 +1,78 @@
package app.telefon.call
import android.telecom.Call
import android.telecom.CallAudioState
import android.telecom.VideoProfile
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
enum class Phase { RINGING, DIALING, ACTIVE, HOLDING, DISCONNECTED }
data class CallUi(
val id: String,
val number: String,
val phase: Phase,
/** System.currentTimeMillis() when it became active, for the timer */
val connectedAt: Long?,
val canHold: Boolean,
)
data class AudioUi(val muted: Boolean = false, val route: Int = CallAudioState.ROUTE_EARPIECE, val supported: Int = 0)
/**
* The calls the system gives our InCallService, as UI state. All commands go through here,
* so the notification, the call screen and the service never disagree.
*/
object CallManager {
private val calls = mutableListOf<Call>()
private val _state = MutableStateFlow<List<CallUi>>(emptyList())
val state: StateFlow<List<CallUi>> = _state
private val _audio = MutableStateFlow(AudioUi())
val audio: StateFlow<AudioUi> = _audio
internal var service: CallService? = null
private val connectedAt = mutableMapOf<Call, Long>()
private val callback = object : Call.Callback() {
override fun onStateChanged(call: Call, state: Int) = publish()
override fun onDetailsChanged(call: Call, details: Call.Details) = publish()
}
internal fun added(call: Call) { calls += call; call.registerCallback(callback); publish() }
internal fun removed(call: Call) { call.unregisterCallback(callback); calls -= call; connectedAt.remove(call); publish() }
internal fun audioChanged(a: CallAudioState) { _audio.value = AudioUi(a.isMuted, a.route, a.supportedRouteMask) }
private fun key(c: Call) = Integer.toHexString(System.identityHashCode(c))
private fun find(id: String) = calls.firstOrNull { key(it) == id }
private fun phaseOf(c: Call) = when (c.details.state) {
Call.STATE_RINGING -> Phase.RINGING
Call.STATE_DIALING, Call.STATE_CONNECTING, Call.STATE_SELECT_PHONE_ACCOUNT, Call.STATE_PULLING_CALL -> Phase.DIALING
Call.STATE_ACTIVE -> Phase.ACTIVE
Call.STATE_HOLDING -> Phase.HOLDING
else -> Phase.DISCONNECTED
}
private fun publish() {
_state.value = calls.map { c ->
val phase = phaseOf(c)
if (phase == Phase.ACTIVE && c !in connectedAt) connectedAt[c] = c.details.connectTimeMillis.takeIf { it > 0 } ?: System.currentTimeMillis()
CallUi(
id = key(c),
number = c.details.handle?.schemeSpecificPart.orEmpty(),
phase = phase,
connectedAt = connectedAt[c],
canHold = c.details.can(Call.Details.CAPABILITY_HOLD),
)
}
service?.onCallsChanged(_state.value)
}
fun answer(id: String) = find(id)?.answer(VideoProfile.STATE_AUDIO_ONLY)
fun reject(id: String, message: String? = null) = find(id)?.reject(message != null, message)
fun hangUp(id: String) = find(id)?.disconnect()
fun hold(id: String, on: Boolean) = find(id)?.let { if (on) it.hold() else it.unhold() }
fun dtmf(id: String, c: Char) = find(id)?.let { it.playDtmfTone(c); it.stopDtmfTone() }
fun setMuted(m: Boolean) = service?.setMuted(m)
@Suppress("DEPRECATION")
fun setRoute(route: Int) = service?.setAudioRoute(route)
}
@@ -0,0 +1,125 @@
package app.telefon.call
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.PowerManager
import android.telecom.Call
import android.telecom.CallAudioState
import android.telecom.InCallService
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import app.telefon.R
import app.telefon.data.Contacts
/** Bound by the system while calls exist, once this app is the default phone app. */
class CallService : InCallService() {
private var proximity: PowerManager.WakeLock? = null
override fun onCreate() { super.onCreate(); CallManager.service = this; channels(this) }
override fun onDestroy() { CallManager.service = null; releaseProximity(); super.onDestroy() }
override fun onCallAdded(call: Call) {
CallManager.added(call)
// a crash here would make Telecom switch to the system phone app, so failures are logged, not thrown
runCatching { if (call.details.state == Call.STATE_RINGING) showIncoming(call) else startActivity(CallActivity.intent(this)) }
.onFailure { android.util.Log.e("Telefon", "onCallAdded", it); runCatching { startActivity(CallActivity.intent(this)) } }
}
override fun onCallRemoved(call: Call) = CallManager.removed(call)
@Deprecated("CallEndpoint API on 34+, the audio state still works")
override fun onCallAudioStateChanged(audioState: CallAudioState) {
CallManager.audioChanged(audioState)
updateProximity(audioState.route)
}
internal fun onCallsChanged(calls: List<CallUi>) = runCatching { updateNotification(calls) }.onFailure { android.util.Log.e("Telefon", "notification", it) }
private fun updateNotification(calls: List<CallUi>) {
val nm = getSystemService(NotificationManager::class.java)
val ringing = calls.firstOrNull { it.phase == Phase.RINGING }
val current = calls.firstOrNull { it.phase != Phase.RINGING && it.phase != Phase.DISCONNECTED }
when {
ringing != null -> Unit // incoming notification already shown
current != null -> nm.notify(ID, ongoing(current))
else -> { nm.cancel(ID); releaseProximity() }
}
if (ringing == null && current == null) nm.cancel(ID)
}
private fun showIncoming(call: Call) {
val number = call.details.handle?.schemeSpecificPart.orEmpty()
val name = Contacts.lookupName(this, number) ?: number.ifEmpty { getString(R.string.unknown) }
val full = CallActivity.pending(this)
val person = Person.Builder().setName(name).setImportant(true).build()
val n = NotificationCompat.Builder(this, CH_INCOMING).setSmallIcon(R.drawable.ic_stat_call)
.setContentTitle(name).setContentText(getString(R.string.incoming_call))
.setCategory(NotificationCompat.CATEGORY_CALL).setPriority(NotificationCompat.PRIORITY_MAX)
.setOngoing(true).setFullScreenIntent(full, true).setContentIntent(full)
.setStyle(NotificationCompat.CallStyle.forIncomingCall(person, action(ACTION_DECLINE), action(ACTION_ANSWER)))
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC).build()
getSystemService(NotificationManager::class.java).notify(ID, n)
}
private fun ongoing(c: CallUi): Notification {
val name = Contacts.lookupName(this, c.number) ?: c.number
// not CallStyle: that style is only allowed with a foreground service or a full-screen intent
// and throws otherwise, which made Telecom fall back to the system phone app
return NotificationCompat.Builder(this, CH_ONGOING).setSmallIcon(R.drawable.ic_stat_call)
.setContentTitle(name).setContentText(getString(R.string.ongoing_call)).setContentIntent(CallActivity.pending(this)).setOngoing(true).setSilent(true)
.addAction(0, getString(R.string.hang_up), action(ACTION_HANGUP))
.apply { c.connectedAt?.let { setUsesChronometer(true).setWhen(it).setShowWhen(true) } }
.setCategory(NotificationCompat.CATEGORY_CALL).build()
}
private fun action(a: String) = PendingIntent.getBroadcast(
this, a.hashCode(), Intent(this, CallActionReceiver::class.java).setAction(a), PendingIntent.FLAG_IMMUTABLE,
)
/** Screen off when the phone is at the ear, only on the earpiece. */
@SuppressLint("WakelockTimeout")
private fun updateProximity(route: Int) {
val inCall = CallManager.state.value.any { it.phase == Phase.ACTIVE || it.phase == Phase.DIALING }
if (route == CallAudioState.ROUTE_EARPIECE && inCall) {
if (proximity == null) proximity = getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "telefon:proximity").also { it.acquire() }
} else releaseProximity()
}
private fun releaseProximity() { proximity?.takeIf { it.isHeld }?.release(); proximity = null }
companion object {
const val ID = 1
const val CH_INCOMING = "incoming"
const val CH_ONGOING = "ongoing"
const val ACTION_ANSWER = "app.telefon.ANSWER"
const val ACTION_DECLINE = "app.telefon.DECLINE"
const val ACTION_HANGUP = "app.telefon.HANGUP"
fun channels(context: Context) {
val nm = context.getSystemService(NotificationManager::class.java)
nm.createNotificationChannels(listOf(
// the platform plays the ringtone for the default dialer; our channel stays silent
NotificationChannel(CH_INCOMING, context.getString(R.string.ch_incoming), NotificationManager.IMPORTANCE_HIGH).apply { setSound(null, null) },
NotificationChannel(CH_ONGOING, context.getString(R.string.ch_ongoing), NotificationManager.IMPORTANCE_LOW),
))
}
}
}
class CallActionReceiver : android.content.BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val ringing = CallManager.state.value.firstOrNull { it.phase == Phase.RINGING }
val current = CallManager.state.value.firstOrNull { it.phase != Phase.RINGING }
when (intent.action) {
CallService.ACTION_ANSWER -> ringing?.let { CallManager.answer(it.id); context.startActivity(CallActivity.intent(context)) }
CallService.ACTION_DECLINE -> ringing?.let { CallManager.reject(it.id) }
CallService.ACTION_HANGUP -> current?.let { CallManager.hangUp(it.id) }
}
}
}
@@ -0,0 +1,159 @@
package app.telefon.data
import android.Manifest
import android.content.ContentResolver
import android.content.Context
import android.content.pm.PackageManager
import android.database.ContentObserver
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.CallLog
import android.provider.ContactsContract
import android.telephony.PhoneNumberUtils
import androidx.core.content.ContextCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
import java.util.Locale
enum class Direction { INCOMING, OUTGOING, MISSED, REJECTED, BLOCKED, VOICEMAIL }
data class CallEntry(
val id: Long, val number: String, val name: String?, val photo: Uri?, val direction: Direction,
val date: Long, val durationS: Long, val label: String?, val count: Int = 1,
)
data class Person(val id: Long, val lookupKey: String, val name: String, val photo: Uri?, val starred: Boolean, val phones: List<Phone>)
data class Phone(val number: String, val label: String)
private fun Context.has(p: String) = ContextCompat.checkSelfPermission(this, p) == PackageManager.PERMISSION_GRANTED
/** Emits again whenever the given content changes. */
private fun <T> ContentResolver.observe(uri: Uri, load: () -> T): Flow<T> = callbackFlow {
val obs = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) { trySend(load()) }
}
registerContentObserver(uri, true, obs)
trySend(load())
awaitClose { unregisterContentObserver(obs) }
}.flowOn(Dispatchers.IO)
object Contacts {
fun lookupName(context: Context, number: String): String? = lookup(context, number)?.first
/** name and photo for a number (PhoneLookup handles formatting differences) */
fun lookup(context: Context, number: String): Pair<String, Uri?>? {
if (number.isBlank() || !context.has(Manifest.permission.READ_CONTACTS)) return null
val uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number))
return runCatching {
context.contentResolver.query(uri, arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME, ContactsContract.PhoneLookup.PHOTO_URI), null, null, null)?.use { c ->
if (c.moveToFirst()) c.getString(0) to c.getString(1)?.let(Uri::parse) else null
}
}.getOrNull()
}
fun callLog(context: Context): Flow<List<CallEntry>> = context.contentResolver.observe(CallLog.Calls.CONTENT_URI) { loadLog(context) }
private fun loadLog(context: Context): List<CallEntry> {
if (!context.has(Manifest.permission.READ_CALL_LOG)) return emptyList()
val out = mutableListOf<CallEntry>()
val cols = arrayOf(CallLog.Calls._ID, CallLog.Calls.NUMBER, CallLog.Calls.CACHED_NAME, CallLog.Calls.CACHED_PHOTO_URI,
CallLog.Calls.TYPE, CallLog.Calls.DATE, CallLog.Calls.DURATION, CallLog.Calls.CACHED_NUMBER_LABEL, CallLog.Calls.CACHED_NUMBER_TYPE)
runCatching {
context.contentResolver.query(CallLog.Calls.CONTENT_URI, cols, null, null, "${CallLog.Calls.DATE} DESC LIMIT 300")?.use { c ->
while (c.moveToNext()) {
val type = c.getInt(4)
val labelType = c.getInt(8)
val label = c.getString(7) ?: if (labelType != 0) ContactsContract.CommonDataKinds.Phone.getTypeLabel(context.resources, labelType, "").toString() else null
out += CallEntry(
id = c.getLong(0), number = c.getString(1).orEmpty(), name = c.getString(2)?.takeIf { it.isNotBlank() },
photo = c.getString(3)?.let(Uri::parse),
direction = when (type) {
CallLog.Calls.OUTGOING_TYPE -> Direction.OUTGOING
CallLog.Calls.MISSED_TYPE -> Direction.MISSED
CallLog.Calls.REJECTED_TYPE -> Direction.REJECTED
CallLog.Calls.BLOCKED_TYPE -> Direction.BLOCKED
CallLog.Calls.VOICEMAIL_TYPE -> Direction.VOICEMAIL
else -> Direction.INCOMING
},
date = c.getLong(5), durationS = c.getLong(6), label = label,
)
}
}
}
// consecutive calls with the same number and direction collapse into one row with a count
return out.fold(mutableListOf<CallEntry>()) { acc, e ->
val last = acc.lastOrNull()
if (last != null && same(last.number, e.number) && last.direction == e.direction && sameDay(last.date, e.date)) acc[acc.lastIndex] = last.copy(count = last.count + 1)
else acc += e
acc
}
}
private fun same(a: String, b: String) = PhoneNumberUtils.areSamePhoneNumber(a, b, Locale.getDefault().country.ifEmpty { "DE" })
private fun sameDay(a: Long, b: Long) = a / 86_400_000 == b / 86_400_000
fun people(context: Context): Flow<List<Person>> = context.contentResolver.observe(ContactsContract.Contacts.CONTENT_URI) { loadPeople(context) }
private fun loadPeople(context: Context): List<Person> {
if (!context.has(Manifest.permission.READ_CONTACTS)) return emptyList()
val phones = mutableMapOf<Long, MutableList<Phone>>()
runCatching {
context.contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
arrayOf(ContactsContract.CommonDataKinds.Phone.CONTACT_ID, ContactsContract.CommonDataKinds.Phone.NUMBER, ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.LABEL),
null, null, null,
)?.use { c ->
while (c.moveToNext()) {
val label = ContactsContract.CommonDataKinds.Phone.getTypeLabel(context.resources, c.getInt(2), c.getString(3) ?: "").toString()
phones.getOrPut(c.getLong(0)) { mutableListOf() }.let { list -> val n = c.getString(1).orEmpty(); if (list.none { same(it.number, n) }) list += Phone(n, label) }
}
}
}
val out = mutableListOf<Person>()
runCatching {
context.contentResolver.query(
ContactsContract.Contacts.CONTENT_URI,
arrayOf(ContactsContract.Contacts._ID, ContactsContract.Contacts.LOOKUP_KEY, ContactsContract.Contacts.DISPLAY_NAME_PRIMARY, ContactsContract.Contacts.PHOTO_URI, ContactsContract.Contacts.STARRED),
"${ContactsContract.Contacts.HAS_PHONE_NUMBER} = 1", null, "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} COLLATE LOCALIZED ASC",
)?.use { c ->
while (c.moveToNext()) {
val id = c.getLong(0)
out += Person(id, c.getString(1).orEmpty(), c.getString(2).orEmpty(), c.getString(3)?.let(Uri::parse), c.getInt(4) == 1, phones[id].orEmpty())
}
}
}
return out
}
fun setStarred(context: Context, id: Long, starred: Boolean) = runCatching {
context.contentResolver.update(
ContactsContract.Contacts.CONTENT_URI, android.content.ContentValues().apply { put(ContactsContract.Contacts.STARRED, if (starred) 1 else 0) },
"${ContactsContract.Contacts._ID} = ?", arrayOf(id.toString()),
)
}
fun deleteLogEntries(context: Context, number: String) = runCatching {
context.contentResolver.delete(CallLog.Calls.CONTENT_URI, "${CallLog.Calls.NUMBER} = ?", arrayOf(number))
}
}
/** T9: 2=abc, 3=def … Matches the digits against name word starts and against the number. */
object T9 {
private val map = mapOf('a' to '2', 'b' to '2', 'c' to '2', 'ä' to '2', 'd' to '3', 'e' to '3', 'f' to '3', 'g' to '4', 'h' to '4', 'i' to '4',
'j' to '5', 'k' to '5', 'l' to '5', 'm' to '6', 'n' to '6', 'o' to '6', 'ö' to '6', 'p' to '7', 'q' to '7', 'r' to '7', 's' to '7', 'ß' to '7',
't' to '8', 'u' to '8', 'v' to '8', 'ü' to '8', 'w' to '9', 'x' to '9', 'y' to '9', 'z' to '9')
fun digits(name: String) = name.lowercase().map { map[it] ?: if (it.isDigit()) it else ' ' }.joinToString("")
fun matches(p: Person, input: String): Boolean {
if (input.isEmpty()) return false
val words = digits(p.name).split(' ').filter { it.isNotEmpty() }
val joined = words.joinToString("")
return words.any { it.startsWith(input) } || joined.startsWith(input) ||
p.phones.any { it.number.filter(Char::isDigit).contains(input) }
}
}
@@ -0,0 +1,74 @@
package app.telefon.ui.components
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin
/** 12-wave scalloped circle for the call screens. */
object CookieShape : Shape {
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
val c = Offset(size.width / 2, size.height / 2); val r = size.minDimension / 2 * .94f
val p = Path()
for (i in 0..180) {
val a = 2 * PI * i / 180
val rr = r * (1 + .05f * cos(12 * a).toFloat())
val x = c.x + rr * cos(a).toFloat(); val y = c.y + rr * sin(a).toFloat()
if (i == 0) p.moveTo(x, y) else p.lineTo(x, y)
}
p.close()
return Outline.Generic(p)
}
}
private val palette = listOf(0xFFD3E3FD, 0xFFC4EED0, 0xFFFFDAD6, 0xFFE9DDFF, 0xFFFFE08B, 0xFFC2E7FF).map { Color(it) }
private val onPalette = listOf(0xFF041E49, 0xFF072711, 0xFF410002, 0xFF21005D, 0xFF241A00, 0xFF001E2F).map { Color(it) }
@Composable
fun Avatar(name: String?, photo: Uri?, size: Dp, cookie: Boolean = false, modifier: Modifier = Modifier) {
val ctx = LocalContext.current
val bmp = produceState<ImageBitmap?>(null, photo) {
value = photo?.let { u -> withContext(Dispatchers.IO) { runCatching { ctx.contentResolver.openInputStream(u)?.use { BitmapFactory.decodeStream(it)?.asImageBitmap() } }.getOrNull() } }
}.value
val shape = if (cookie) CookieShape else CircleShape
val i = abs((name ?: "").hashCode()) % palette.size
Box(modifier.size(size).clip(shape).background(palette[i]), contentAlignment = Alignment.Center) {
when {
bmp != null -> Image(bmp, null, Modifier.size(size), contentScale = ContentScale.Crop)
!name.isNullOrBlank() && name.first().isLetter() -> Text(name.first().uppercase(), color = onPalette[i], fontSize = (size.value * .42f).sp)
else -> Icon(Icons.Rounded.Person, null, tint = onPalette[i], modifier = Modifier.size(size * .55f))
}
}
}
@@ -0,0 +1,16 @@
package app.telefon.ui.components
import android.telephony.PhoneNumberUtils
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
object Fmt {
fun number(n: String): String = if (n.isBlank()) "" else PhoneNumberUtils.formatNumber(n, Locale.getDefault().country.ifEmpty { "DE" }) ?: n
fun duration(ms: Long): String { val s = ms / 1000; return if (s >= 3600) "%d:%02d:%02d".format(s / 3600, s / 60 % 60, s % 60) else "%d:%02d".format(s / 60, s % 60) }
fun time(at: Long): String = Instant.ofEpochMilli(at).atZone(ZoneId.systemDefault()).toLocalTime().format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT))
fun day(at: Long): LocalDate = Instant.ofEpochMilli(at).atZone(ZoneId.systemDefault()).toLocalDate()
}
@@ -0,0 +1,161 @@
package app.telefon.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import app.telefon.ui.theme.WSpace
import app.telefon.ui.theme.WType
/*
* Settings in the style of current Pixel system settings: grouped tiles with large outer
* corners and small inner ones, 2dp gaps, tonal icon circles, values as summaries,
* choices in dialogs.
*/
private val Outer = 24.dp
private val Inner = 4.dp
private fun tileShape(index: Int, count: Int): Shape = when {
count == 1 -> RoundedCornerShape(Outer)
index == 0 -> RoundedCornerShape(topStart = Outer, topEnd = Outer, bottomStart = Inner, bottomEnd = Inner)
index == count - 1 -> RoundedCornerShape(topStart = Inner, topEnd = Inner, bottomStart = Outer, bottomEnd = Outer)
else -> RoundedCornerShape(Inner)
}
class GroupScope internal constructor() {
internal val items = mutableListOf<@Composable (Shape) -> Unit>()
fun item(content: @Composable (Shape) -> Unit) { items += content }
}
@Composable
fun SettingsGroup(title: String? = null, modifier: Modifier = Modifier, content: GroupScope.() -> Unit) {
val scope = GroupScope().apply(content)
Column(modifier.padding(horizontal = WSpace.s16)) {
if (title != null) {
Text(
title, style = WType.label.copy(fontSize = WType.bodySm.fontSize), color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = WSpace.s16, top = WSpace.s24, bottom = WSpace.s8).semantics { heading() },
)
} else Spacer(Modifier.padding(top = WSpace.s16))
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
scope.items.forEachIndexed { i, item -> item(tileShape(i, scope.items.size)) }
}
}
}
@Composable
private fun Tile(shape: Shape, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Box(
modifier.fillMaxWidth().clip(shape).background(MaterialTheme.colorScheme.surfaceContainerLowest).heightIn(min = 72.dp),
contentAlignment = Alignment.CenterStart,
) { content() }
}
@Composable
private fun RowContent(icon: ImageVector?, title: String, summary: String?, trailing: (@Composable () -> Unit)?) {
Row(Modifier.fillMaxWidth().padding(horizontal = WSpace.s16, vertical = WSpace.s16), verticalAlignment = Alignment.CenterVertically) {
if (icon != null) {
Box(Modifier.size(40.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center) {
Icon(icon, null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(22.dp))
}
Spacer(Modifier.width(WSpace.s16))
}
Column(Modifier.weight(1f)) {
Text(title, style = WType.body, color = MaterialTheme.colorScheme.onSurface)
if (summary != null) Text(summary, style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (trailing != null) { Spacer(Modifier.width(WSpace.s12)); trailing() }
}
}
@Composable
fun SettingsItem(shape: Shape, title: String, summary: String? = null, icon: ImageVector? = null, onClick: (() -> Unit)? = null, trailing: (@Composable () -> Unit)? = null) {
Tile(shape, if (onClick != null) Modifier.clip(shape).clickable(onClick = onClick) else Modifier) { RowContent(icon, title, summary, trailing) }
}
@Composable
fun SettingsSwitch(shape: Shape, title: String, checked: Boolean, onChange: (Boolean) -> Unit, summary: String? = null, icon: ImageVector? = null) {
Tile(shape, Modifier.clip(shape).toggleable(checked, role = Role.Switch, onValueChange = onChange)) {
RowContent(icon, title, summary) {
Switch(
checked, onCheckedChange = null,
thumbContent = if (checked) { { Icon(Icons.Rounded.Check, null, Modifier.size(SwitchDefaults.IconSize)) } } else null,
)
}
}
}
/** A tile whose value is picked from a list in a dialog, like Pixel's radio settings. */
@Composable
fun <T> SettingsChoice(shape: Shape, title: String, options: List<T>, selected: T, label: @Composable (T) -> String, onPick: (T) -> Unit, icon: ImageVector? = null) {
var open by remember { mutableStateOf(false) }
SettingsItem(shape, title, label(selected), icon, onClick = { open = true })
if (open) {
AlertDialog(
onDismissRequest = { open = false },
title = { Text(title) },
text = {
Column(Modifier.selectableGroup()) {
options.forEach { o ->
Row(
Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp))
.selectable(o == selected, role = Role.RadioButton) { onPick(o); open = false }
.padding(vertical = WSpace.s12, horizontal = WSpace.s4),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(o == selected, onClick = null)
Spacer(Modifier.width(WSpace.s16))
Text(label(o), style = WType.body)
}
}
}
},
confirmButton = { TextButton(onClick = { open = false }) { Text(androidx.compose.ui.res.stringResource(android.R.string.cancel)) } },
)
}
}
/** Free content inside a group tile (swatches, sliders). */
@Composable
fun SettingsCustom(shape: Shape, content: @Composable ColumnScope.() -> Unit) {
Tile(shape) { Column(Modifier.fillMaxWidth().padding(WSpace.s16), content = content) }
}
@@ -0,0 +1,337 @@
package app.telefon.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
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.automirrored.rounded.CallMade
import androidx.compose.material.icons.automirrored.rounded.CallMissed
import androidx.compose.material.icons.automirrored.rounded.CallReceived
import androidx.compose.material.icons.rounded.Block
import androidx.compose.material.icons.rounded.Call
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarBorder
import androidx.compose.material.icons.rounded.Voicemail
import androidx.compose.material.icons.outlined.Sms
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.telefon.R
import app.telefon.data.CallEntry
import app.telefon.data.Direction
import app.telefon.data.Person
import app.telefon.data.T9
import app.telefon.ui.components.Avatar
import app.telefon.ui.components.Fmt
import app.telefon.ui.theme.LocalCallColors
import app.telefon.ui.theme.WType
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
enum class Filter { ALL, MISSED, CONTACTS }
private fun groupShape(i: Int, n: Int): Shape = when {
n == 1 -> RoundedCornerShape(24.dp)
i == 0 -> RoundedCornerShape(24.dp, 24.dp, 4.dp, 4.dp)
i == n - 1 -> RoundedCornerShape(4.dp, 4.dp, 24.dp, 24.dp)
else -> RoundedCornerShape(4.dp)
}
/** S01 */
@Composable
fun HomeScreen(
log: List<CallEntry>, people: List<Person>, pad: PaddingValues,
onCall: (String) -> Unit, onOpenNumber: (String) -> Unit, onOpenPerson: (Person) -> Unit, onSearch: () -> Unit,
) {
var filter by rememberSaveable { mutableStateOf(Filter.ALL) }
val scheme = MaterialTheme.colorScheme
val res = LocalContext.current.resources
val favorites = people.filter { it.starred }
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = pad.calculateTopPadding(), bottom = pad.calculateBottomPadding() + 16.dp)) {
item {
Row(
Modifier.padding(16.dp).fillMaxWidth().height(56.dp).clip(RoundedCornerShape(28.dp)).background(scheme.surfaceContainer).clickable(onClick = onSearch).padding(horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(stringResource(R.string.search_contacts), style = WType.body, color = scheme.onSurfaceVariant, modifier = Modifier.weight(1f))
Icon(Icons.Rounded.Search, null, tint = scheme.onSurfaceVariant)
}
}
item {
LazyRow(contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(Filter.entries) { f ->
FilterChip(filter == f, { filter = f }, label = {
Text(stringResource(when (f) { Filter.ALL -> R.string.f_all; Filter.MISSED -> R.string.f_missed; Filter.CONTACTS -> R.string.f_contacts }))
})
}
}
}
if (filter == Filter.CONTACTS) {
items(people, key = { "p" + it.id }) { p ->
Row(Modifier.fillMaxWidth().clickable { onOpenPerson(p) }.padding(horizontal = 24.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
Avatar(p.name, p.photo, 40.dp); Spacer(Modifier.width(16.dp)); Text(p.name, style = WType.body, modifier = Modifier.weight(1f))
p.phones.firstOrNull()?.let { IconButton(onClick = { onCall(it.number) }) { Icon(Icons.Rounded.Call, stringResource(R.string.call)) } }
}
}
if (people.isEmpty()) item { Empty(stringResource(R.string.no_contacts)) }
return@LazyColumn
}
if (favorites.isNotEmpty() && filter == Filter.ALL) {
item { Header(stringResource(R.string.favorites)) }
item {
LazyRow(contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
items(favorites, key = { it.id }) { p ->
Column(Modifier.width(64.dp).combinedClickable(onClick = { p.phones.firstOrNull()?.let { onCall(it.number) } }, onLongClick = { onOpenPerson(p) }),
horizontalAlignment = Alignment.CenterHorizontally) {
Avatar(p.name, p.photo, 56.dp)
Text(p.name.substringBefore(' '), style = WType.label, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp))
}
}
}
}
}
val shown = if (filter == Filter.MISSED) log.filter { it.direction == Direction.MISSED } else log
if (shown.isEmpty()) item { Empty(stringResource(if (filter == Filter.MISSED) R.string.no_missed else R.string.no_calls)) }
shown.groupBy { Fmt.day(it.date) }.forEach { (day, calls) ->
item(key = "h$day") {
Header(when (day) { LocalDate.now() -> res.getString(R.string.today); LocalDate.now().minusDays(1) -> res.getString(R.string.yesterday); else -> day.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)) })
}
itemsIndexed(calls, key = { _, c -> c.id }) { i, c ->
CallRow(c, groupShape(i, calls.size), { onCall(c.number) }) { onOpenNumber(c.number) }
}
}
}
}
@Composable private fun Header(t: String) = Text(t, style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 24.dp, top = 20.dp, bottom = 8.dp))
@Composable private fun Empty(t: String) = Text(t, style = WType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(48.dp))
@Composable
private fun CallRow(c: CallEntry, shape: Shape, onCall: () -> Unit, onOpen: () -> Unit) {
val scheme = MaterialTheme.colorScheme
val missed = c.direction == Direction.MISSED
val color = if (missed) LocalCallColors.current.missed else scheme.onSurfaceVariant
val icon = when (c.direction) {
Direction.OUTGOING -> Icons.AutoMirrored.Rounded.CallMade
Direction.MISSED -> Icons.AutoMirrored.Rounded.CallMissed
Direction.REJECTED, Direction.BLOCKED -> Icons.Rounded.Block
Direction.VOICEMAIL -> Icons.Rounded.Voicemail
Direction.INCOMING -> Icons.AutoMirrored.Rounded.CallReceived
}
val typeText = stringResource(when (c.direction) {
Direction.OUTGOING -> R.string.d_out; Direction.MISSED -> R.string.d_missed; Direction.REJECTED -> R.string.d_rejected
Direction.BLOCKED -> R.string.d_blocked; Direction.VOICEMAIL -> R.string.d_voicemail; Direction.INCOMING -> R.string.d_in
})
val title = (c.name ?: Fmt.number(c.number).ifEmpty { stringResource(R.string.unknown) }) + if (c.count > 1) " (${c.count})" else ""
Row(
Modifier.padding(horizontal = 16.dp, vertical = 1.dp).fillMaxWidth().clip(shape).background(scheme.surfaceContainerLowest).clickable(onClick = onOpen).padding(start = 16.dp, top = 12.dp, bottom = 12.dp, end = 4.dp)
.semantics(mergeDescendants = true) { contentDescription = "$title, $typeText, ${Fmt.time(c.date)}" },
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(c.name, c.photo, 40.dp)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text(title, style = WType.body, color = if (missed) color else scheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, null, Modifier.size(14.dp), tint = color)
Text(" " + (c.label?.takeIf { c.name != null } ?: typeText) + " · " + Fmt.time(c.date), style = WType.bodySm, color = color, maxLines = 1)
}
}
if (c.number.isNotBlank()) IconButton(onClick = onCall) { Icon(Icons.Rounded.Call, stringResource(R.string.call)) }
}
}
/** S02 */
@Composable
fun SearchScreen(people: List<Person>, pad: PaddingValues, onCall: (String) -> Unit, onOpenPerson: (Person) -> Unit) {
var q by rememberSaveable { mutableStateOf("") }
val hits = remember(q, people) {
if (q.isBlank()) people.take(40) else people.filter { p -> p.name.contains(q, true) || p.phones.any { it.number.filter(Char::isDigit).contains(q.filter(Char::isDigit).ifEmpty { "§" }) } }
}
Column(Modifier.fillMaxSize().padding(pad)) {
TextField(q, { q = it }, Modifier.fillMaxWidth().padding(16.dp).clip(RoundedCornerShape(28.dp)), placeholder = { Text(stringResource(R.string.search_contacts)) },
leadingIcon = { Icon(Icons.Rounded.Search, null) }, singleLine = true,
colors = TextFieldDefaults.colors(focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent))
LazyColumn {
if (q.any(Char::isDigit)) item {
Row(Modifier.fillMaxWidth().clickable { onCall(q) }.padding(horizontal = 24.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.Call, null); Spacer(Modifier.width(16.dp)); Text(stringResource(R.string.call_number, q), style = WType.body)
}
}
items(hits, key = { it.id }) { p ->
Row(Modifier.fillMaxWidth().clickable { onOpenPerson(p) }.padding(horizontal = 24.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
Avatar(p.name, p.photo, 40.dp); Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) { Text(p.name, style = WType.body); p.phones.firstOrNull()?.let { Text("${it.label} · ${Fmt.number(it.number)}", style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant) } }
}
}
}
}
}
/** 3×4 keys with letters; used by S03 and the in-call keypad. */
@Composable
fun DialPad(onKey: (Char) -> Unit) {
val letters = mapOf('2' to "ABC", '3' to "DEF", '4' to "GHI", '5' to "JKL", '6' to "MNO", '7' to "PQRS", '8' to "TUV", '9' to "WXYZ", '0' to "+")
val view = androidx.compose.ui.platform.LocalView.current
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(horizontal = 32.dp)) {
listOf("123", "456", "789", "*0#").forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
row.forEach { k ->
Column(
Modifier.weight(1f).height(68.dp).clip(RoundedCornerShape(34.dp)).background(MaterialTheme.colorScheme.surfaceContainer)
.clickable { view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP); onKey(k) },
horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center,
) {
Text(k.toString(), style = WType.key)
letters[k]?.let { Text(it, style = WType.label, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
}
}
}
}
}
/** S03 */
@Composable
fun KeypadScreen(people: List<Person>, pad: PaddingValues, initial: String, onCall: (String) -> Unit) {
var number by rememberSaveable(initial) { mutableStateOf(initial) }
val digits = number.filter(Char::isDigit)
val matches = remember(digits, people) { if (digits.isEmpty()) emptyList() else people.filter { T9.matches(it, digits) }.take(3) }
Column(Modifier.fillMaxSize().padding(pad), horizontalAlignment = Alignment.CenterHorizontally) {
Column(Modifier.weight(1f).fillMaxWidth()) {
matches.forEach { p ->
Row(Modifier.fillMaxWidth().clickable { p.phones.firstOrNull()?.let { onCall(it.number) } }.padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Avatar(p.name, p.photo, 40.dp); Spacer(Modifier.width(16.dp))
Column { Text(p.name, style = WType.body); p.phones.firstOrNull()?.let { Text(Fmt.number(it.number), style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant) } }
}
}
}
Row(Modifier.fillMaxWidth().padding(horizontal = 24.dp), verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.width(48.dp))
Text(Fmt.number(number).ifEmpty { number }, style = WType.dial, textAlign = TextAlign.Center, maxLines = 1, modifier = Modifier.weight(1f))
if (number.isNotEmpty()) Box(Modifier.size(48.dp).combinedClickable(onClick = { number = number.dropLast(1) }, onLongClick = { number = "" }), contentAlignment = Alignment.Center) {
Icon(Icons.AutoMirrored.Rounded.Backspace, stringResource(R.string.backspace))
} else Spacer(Modifier.width(48.dp))
}
Spacer(Modifier.height(16.dp))
DialPad { number += it }
Spacer(Modifier.height(16.dp))
val cc = LocalCallColors.current
Box(Modifier.size(72.dp).clip(CircleShape).background(cc.accept).clickable(enabled = number.isNotBlank()) { onCall(number) }, contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.Call, stringResource(R.string.call), tint = cc.onAccept, modifier = Modifier.size(32.dp))
}
Spacer(Modifier.height(24.dp))
}
}
/** S04 */
@Composable
fun VoicemailScreen(number: String?, pad: PaddingValues, onCall: (String) -> Unit) {
Column(Modifier.fillMaxSize().padding(pad).padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Icon(Icons.Rounded.Voicemail, null, Modifier.size(72.dp), tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(16.dp))
Text(if (number != null) stringResource(R.string.voicemail_number, Fmt.number(number)) else stringResource(R.string.voicemail_none), style = WType.body, textAlign = TextAlign.Center)
Spacer(Modifier.height(24.dp))
if (number != null) Button(onClick = { onCall(number) }) { Text(stringResource(R.string.voicemail_call)) }
}
}
/** S05: a contact, or just a number from the log. */
@Composable
fun DetailsScreen(
person: Person?, number: String?, log: List<CallEntry>, pad: PaddingValues,
onCall: (String) -> Unit, onSms: (String) -> Unit, onStar: (Person) -> Unit, onAddContact: (String) -> Unit, onOpenContacts: (Person) -> Unit,
) {
val numbers = person?.phones?.map { it.number } ?: listOfNotNull(number)
val calls = log.filter { e -> numbers.any { android.telephony.PhoneNumberUtils.compare(it, e.number) } }
LazyColumn(Modifier.fillMaxSize(), contentPadding = pad) {
item {
Column(Modifier.fillMaxWidth().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Avatar(person?.name, person?.photo, 120.dp, cookie = true)
Spacer(Modifier.height(16.dp))
Text(person?.name ?: Fmt.number(number.orEmpty()), style = WType.caller.copy(fontSize = WType.title.fontSize * 1.3f), textAlign = TextAlign.Center)
Row(Modifier.padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
numbers.firstOrNull()?.let { n ->
Action(Icons.Rounded.Call, stringResource(R.string.call)) { onCall(n) }
Action(Icons.Outlined.Sms, stringResource(R.string.sms)) { onSms(n) }
}
if (person != null) Action(if (person.starred) Icons.Rounded.Star else Icons.Rounded.StarBorder, stringResource(R.string.favorite)) { onStar(person) }
else number?.let { Action(Icons.Outlined.PersonAdd, stringResource(R.string.add_contact)) { onAddContact(it) } }
}
}
}
person?.phones?.let { phones ->
itemsIndexed(phones) { i, p ->
Row(Modifier.padding(horizontal = 16.dp, vertical = 1.dp).fillMaxWidth().clip(groupShape(i, phones.size)).background(MaterialTheme.colorScheme.surfaceContainerLowest)
.clickable { onCall(p.number) }.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) { Text(Fmt.number(p.number), style = WType.body); Text(p.label, style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant) }
IconButton(onClick = { onSms(p.number) }) { Icon(Icons.Outlined.Sms, stringResource(R.string.sms)) }
}
}
item { androidx.compose.material3.TextButton(onClick = { onOpenContacts(person) }, modifier = Modifier.padding(start = 16.dp)) { Text(stringResource(R.string.open_in_contacts)) } }
}
if (calls.isNotEmpty()) item { Header(stringResource(R.string.history)) }
itemsIndexed(calls.take(30)) { i, c ->
Row(Modifier.padding(horizontal = 16.dp, vertical = 1.dp).fillMaxWidth().clip(groupShape(i, calls.take(30).size)).background(MaterialTheme.colorScheme.surfaceContainerLowest).padding(16.dp)) {
Text(c.date.let { java.time.Instant.ofEpochMilli(it).atZone(java.time.ZoneId.systemDefault()).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)) }, style = WType.bodySm, modifier = Modifier.weight(1f))
Text(if (c.durationS > 0) Fmt.duration(c.durationS * 1000) else "–", style = WType.bodySm)
}
}
}
}
@Composable
private fun Action(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, onClick: () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(72.dp)) {
Box(Modifier.size(56.dp).clip(CircleShape).background(MaterialTheme.colorScheme.secondaryContainer).clickable(onClick = onClick), contentAlignment = Alignment.Center) {
Icon(icon, label, tint = MaterialTheme.colorScheme.onSecondaryContainer)
}
Text(label, style = WType.label, modifier = Modifier.padding(top = 6.dp))
}
}
@@ -0,0 +1,39 @@
package app.telefon.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
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
/** Call colours stay fixed (green answer, red hang up) so they read the same on every wallpaper. */
@Immutable
data class CallColors(val accept: Color, val onAccept: Color, val hangup: Color, val onHangup: Color, val missed: Color)
private val LightCall = CallColors(WColor.accept, WColor.onAccept, WColor.hangup, WColor.onHangup, WColor.missed)
private val DarkCall = CallColors(WColor.darkAccept, WColor.darkOnAccept, WColor.darkHangup, WColor.darkOnHangup, WColor.darkMissed)
val LocalCallColors = staticCompositionLocalOf { LightCall }
@Composable
fun PhoneTheme(dynamic: Boolean = true, forceDark: Boolean? = null, content: @Composable () -> Unit) {
val dark = forceDark ?: isSystemInDarkTheme()
val ctx = LocalContext.current
val scheme = when {
dynamic && Build.VERSION.SDK_INT >= 31 -> if (dark) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
dark -> darkColorScheme(primary = WColor.darkToggleOn, onPrimary = WColor.darkOnToggleOn, surface = WColor.darkBg, background = WColor.darkBg,
surfaceContainerLow = WColor.darkCard, surfaceContainerLowest = WColor.darkCard, surfaceContainer = WColor.darkToggle,
secondaryContainer = WColor.darkChipOn, onSecondaryContainer = WColor.darkOnChipOn, onSurface = WColor.darkText, onSurfaceVariant = WColor.darkTextMuted)
else -> lightColorScheme(primary = WColor.onChipOn, surface = WColor.bg, background = WColor.bg,
surfaceContainerLow = WColor.card, surfaceContainerLowest = WColor.card, surfaceContainer = WColor.slideTrack,
secondaryContainer = WColor.chipOn, onSecondaryContainer = WColor.onChipOn, onSurface = WColor.text, onSurfaceVariant = WColor.textMuted)
}
CompositionLocalProvider(LocalCallColors provides if (dark) DarkCall else LightCall) { MaterialTheme(colorScheme = scheme, content = content) }
}
@@ -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
}

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