Author SHA1 Message Date
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
88 changed files with 3003 additions and 102 deletions
+35 -2
View File
@@ -8,6 +8,7 @@
| App | Replaces | Status | Install | | App | Replaces | Status | Install |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| **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 | | **Uhr** (`uhr/`) | Google Clock / Pixel Clock | alpha, in daily testing | [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.uhr%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Uhr%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.uhr%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EUhr%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Euhr-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D) · F-Droid: not yet |
| **Wetter** (`wetter/`) | Google Weather / Pixel Weather | alpha, in daily testing | [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.wetter%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Wetter%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.wetter%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EWetter%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Ewetter-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D) · F-Droid: not yet | | **Wetter** (`wetter/`) | Google Weather / Pixel Weather | alpha, in daily testing | [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.wetter%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FNoGooleApps%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22Wetter%22%2C%22overrideSource%22%3A%22Codeberg%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22appId%5C%22%3A%5C%22app.wetter%5C%22%2C%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5EWetter%20%5C%22%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5Ewetter-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%5C%22autoApkFilterByArch%5C%22%3Afalse%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%7D%22%7D) · F-Droid: not yet |
@@ -43,6 +44,24 @@ A weather app with the look and feel of Pixel Weather: an animated sky that chan
Requires Android 10 (API 29) or newer. Requires Android 10 (API 29) or newer.
### 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 ### 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. 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 +70,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. - **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. - **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). - **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"> <p align="center">
<img src="docs/screenshots/uhr/S01-alarms-dark.png" width="200" alt="Alarm list"> <img src="docs/screenshots/uhr/S01-alarms-dark.png" width="200" alt="Alarm list">
@@ -101,6 +121,16 @@ 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 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
```
**Releases.** Every app is published as a release titled `<App> <version>` (for example `Wetter 0.1.0`, tag `wetter-v0.1.0`) with one asset named `<app>-<version>.apk`. Obtainium shows *"no matching version"* until the first release of an app exists. **Releases.** Every app is published as a release titled `<App> <version>` (for example `Wetter 0.1.0`, tag `wetter-v0.1.0`) with one asset named `<app>-<version>.apk`. Obtainium shows *"no matching version"* until the first release of an app exists.
**F-Droid:** not available yet. The apps contain no proprietary libraries, so an F-Droid build is planned (either the main repository or a self-hosted F-Droid repo). **F-Droid:** not available yet. The apps contain no proprietary libraries, so an F-Droid build is planned (either the main repository or a self-hosted F-Droid repo).
@@ -119,6 +149,8 @@ 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 | | 4 | `replica-build` | The app itself, screen by screen, with a [build log](replica/wetter/build-log.md) and [screenshots](replica/wetter/clone-screens/) rendered from a real API response |
| next | `replica-test`, `replica-diff`, `replica-entrepreneur`, `replica-brand`, `replica-launch`, `replica-deploy`, `replica-backend` | QA, parity scoring against the original, reading real user reviews for improvements, naming and branding, store listing, release | | next | `replica-test`, `replica-diff`, `replica-entrepreneur`, `replica-brand`, `replica-launch`, `replica-deploy`, `replica-backend` | QA, parity scoring against the original, reading real user reviews for improvements, naming and branding, store listing, release |
**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/). **Uhr** went through the same steps from scratch: [recon](replica/uhr/recon.md) (17 screens, 9 flows), [architecture](replica/uhr/architecture.md), [design tokens](replica/uhr/design/tokens.json) (39 pairs AA), [build log](replica/uhr/build-log.md) and [screenshots](replica/uhr/clone-screens/).
The rules Replica enforces, and that this project follows: The rules Replica enforces, and that this project follows:
@@ -142,7 +174,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). Requirements: JDK 17 or newer and the Android SDK (platform 36).
```bash ```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:assembleRelease # APK in app/build/outputs/apk/release/
./gradlew :app:testDebugUnitTest # unit + screenshot tests ./gradlew :app:testDebugUnitTest # unit + screenshot tests
./gradlew :app:recordRoborazziDebug # re-render replica/wetter/clone-screens/ ./gradlew :app:recordRoborazziDebug # re-render replica/wetter/clone-screens/
@@ -155,6 +187,7 @@ 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) replica/<app>/ recon, architecture, design system, build log, screenshots (one folder per app)
wetter/ the weather app (Kotlin, Jetpack Compose, Glance widgets, Room, WorkManager) wetter/ the weather app (Kotlin, Jetpack Compose, Glance widgets, Room, WorkManager)
rechner/ the calculator (Kotlin, Jetpack Compose, own exact expression engine)
uhr/ the clock and alarm app (Kotlin, Jetpack Compose, AlarmManager, Glance, Room) 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

+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

+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 | – | – | | S03 Time picker | 2026-10-17 | done | – | – |
| S04 Sound picker | 2026-10-17 | done | – | own file not device-tested | | S04 Sound picker | 2026-10-17 | done | – | own file not device-tested |
| S05 Pause alarm | 2026-10-17 | done | – | – | | 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 | – | – | | 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 | – | – | | 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 | – | – | | S11 Ringing | 2026-10-17 | done | – | – |
| S12 Timer ringing | 2026-10-17 | done | – | shares the ringing screen | | S12 Timer ringing | 2026-10-17 | done | – | shares the ringing screen |
| S13 Settings | 2026-10-17 | done | timer sound picker, home time zone | – | | 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 timerProgress = Color(0xFF7EC4A8)
val nightText = Color(0xFFC4433A) val nightText = Color(0xFFC4433A)
val nightBg = Color(0xFF000000) val nightBg = Color(0xFF000000)
val sunriseNight = Color(0xFF0D0710)
val sunriseEmber = Color(0xFF5A1A10)
val sunriseDay = Color(0xFFFFD9A0)
val sunriseSun = Color(0xFFFFF3D6)
} }
object WType { object WType {
+310 -51
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", "_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": { "color": {
"bg": "#f8f9ff", "surface": "#ffffff", "card": "#eef0f6", "card-high": "#e2e5ec", "bg": "#f8f9ff",
"border": "#c3c6cf", "border-input": "#73777f", "surface": "#ffffff",
"text": "#191c20", "text-muted": "#43474e", "text-off": "#5c6068", "card": "#eef0f6",
"accent": "#2f5ea8", "on-accent": "#ffffff", "card-high": "#e2e5ec",
"accent-container": "#d6e3ff", "on-accent-container": "#001b3e", "border": "#c3c6cf",
"fab": "#a8c7fa", "on-fab": "#0b2a55", "border-input": "#73777f",
"chip-on": "#2f5ea8", "on-chip-on": "#ffffff", "text": "#191c20",
"secondary-pill": "#dfe2eb", "on-secondary-pill": "#191c20", "text-muted": "#43474e",
"stop": "#ffd8e4", "on-stop": "#4a1426", "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", "danger": "#ba1a1a",
"nav": "#eceef4", "nav-indicator": "#d6e3ff", "nav": "#eceef4",
"nav-indicator": "#d6e3ff",
"dark-bg": "#111318", "dark-surface": "#111318", "dark-card": "#1d2024", "dark-card-high": "#272a2f", "dark-bg": "#111318",
"dark-border": "#43474e", "dark-border-input": "#8d9199", "dark-surface": "#111318",
"dark-text": "#e2e2e9", "dark-text-muted": "#c3c6cf", "dark-text-off": "#9a9ea6", "dark-card": "#1d2024",
"dark-accent": "#a8c7fa", "dark-on-accent": "#062e6f", "dark-card-high": "#272a2f",
"dark-accent-container": "#244a80", "dark-on-accent-container": "#d6e3ff", "dark-border": "#43474e",
"dark-fab": "#a8c7fa", "dark-on-fab": "#062e6f", "dark-border-input": "#8d9199",
"dark-chip-on": "#a8c7fa", "dark-on-chip-on": "#062e6f", "dark-text": "#e2e2e9",
"dark-secondary-pill": "#3a3d43", "dark-on-secondary-pill": "#e2e2e9", "dark-text-muted": "#c3c6cf",
"dark-stop": "#f2b8c9", "dark-on-stop": "#4a1426", "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-danger": "#ffb4ab",
"dark-nav": "#1d2024", "dark-nav-indicator": "#3a3f47", "dark-nav": "#1d2024",
"dark-nav-indicator": "#3a3f47",
"ring-bg": "#1e5a52", "ring-on": "#b4f1e6", "ring-track": "#2b6e65", "ring-thumb": "#3f9a7f", "on-ring-thumb": "#ffffff", "ring-bg": "#1e5a52",
"timer-track": "#3a3d43", "timer-progress": "#7ec4a8", "ring-on": "#b4f1e6",
"night-text": "#c4433a", "night-bg": "#000000" "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": [ "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"], "text",
["on-secondary-pill","secondary-pill"],["on-stop","stop"],["accent","card"],["danger","card"],["text","nav"],["text","nav-indicator"], "bg"
["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"], "text",
["dark-on-secondary-pill","dark-secondary-pill"],["dark-on-stop","dark-stop"],["dark-accent","dark-card"],["dark-danger","dark-card"], "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",
"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": { "font": {
"sans": "Roboto Flex (OFL), system fallback", "sans": "Roboto Flex (OFL), system fallback",
@@ -48,21 +233,88 @@
"mono": "tabular figures (fontFeatureSettings tnum) instead of a mono font for the stopwatch" "mono": "tabular figures (fontFeatureSettings tnum) instead of a mono font for the stopwatch"
}, },
"type": { "type": {
"label-sm": {"size": 12, "line": 16, "weight": 500}, "label-sm": {
"label": {"size": 14, "line": 20, "weight": 500}, "size": 12,
"body-sm": {"size": 14, "line": 20, "weight": 400}, "line": 16,
"body": {"size": 16, "line": 24, "weight": 400}, "weight": 500
"title": {"size": 22, "line": 28, "weight": 400}, },
"headline": {"size": 28, "line": 36, "weight": 400}, "label": {
"alarm-time": {"size": 56, "line": 60, "weight": 600}, "size": 14,
"alarm-ampm": {"size": 20, "line": 24, "weight": 600}, "line": 20,
"timer-time": {"size": 44, "line": 48, "weight": 400}, "weight": 500
"clock-big": {"size": 88, "line": 92, "weight": 400}, },
"stopwatch": {"size": 72, "line": 76, "weight": 500}, "body-sm": {
"ringing": {"size": 104, "line": 108, "weight": 400} "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": { "shape": {
"fab": "circle 96dp (large, centred above the nav bar)", "fab": "circle 96dp (large, centred above the nav bar)",
"stopwatch-primary": "pill 72dp tall, full width", "stopwatch-primary": "pill 72dp tall, full width",
@@ -72,7 +324,14 @@
"motion": { "motion": {
"card-expand": "spring dampingRatio 0.85 stiffness 380", "card-expand": "spring dampingRatio 0.85 stiffness 380",
"slide-act": "spring back dampingRatio 0.7", "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, Snoozed notification with dismiss,notifications,must,yes,yes,
World clock: local time and date,clock,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: 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, Analog or digital clock style,clock,should,yes,yes,
Show seconds,clock,could,yes,yes, Show seconds,clock,could,yes,yes,
Automatic home clock when travelling,clock,could,yes,no, Automatic home clock when travelling,clock,could,yes,no,
Timer keypad h m s,timer,must,yes,yes, Timer keypad h m s,timer,must,yes,yes,
Run several timers at once,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 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 runs in background with notification,timer,must,yes,yes,
Timer ringing screen,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 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, Stopwatch running notification,stopwatch,should,yes,yes,
Bedtime schedule with wake-up alarm,bedtime,should,yes,yes, Bedtime schedule with wake-up alarm,bedtime,should,yes,yes,
Bedtime reminder notification,bedtime,should,yes,yes, Bedtime reminder notification,bedtime,should,yes,yes,
Sunrise alarm (brightening screen),bedtime,could,yes,no, 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,no,own CC0 sounds 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 Screensaver / bedside clock with night mode,display,could,yes,no,DreamService
Digital clock widget,widgets,must,yes,yes, Digital clock widget,widgets,must,yes,yes,
Analog clock widget,widgets,should,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
+2 -2
View File
@@ -16,8 +16,8 @@ android {
applicationId = "app.uhr" applicationId = "app.uhr"
minSdk = 29 minSdk = 29
targetSdk = 36 targetSdk = 36
versionCode = 1 versionCode = 4
versionName = "0.1.0" versionName = "0.2.0"
} }
// Release key lives outside the repo; without it, release builds fall back to the debug key (local only). // Release key lives outside the repo; without it, release builds fall back to the debug key (local only).
@@ -13,6 +13,15 @@ class DebugAlarmActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Graph.init(this) Graph.init(this)
if (intent.getBooleanExtra("sunrise", false)) {
// through the real path: AlarmManager -> receiver -> full-screen notification, 20 s from now
Graph.repo.scheduler.scheduleSunrise(System.currentTimeMillis() + app.uhr.alarm.SunriseActivity.DURATION_MS + 20_000L)
finish(); return
}
if (intent.getBooleanExtra("cities", false)) {
runBlocking { app.uhr.data.Zones.search("").let { } ; listOf("Tokyo", "New_York", "London").forEach { c -> app.uhr.data.Zones.search(c.replace('_', ' ')).firstOrNull()?.let { Graph.repo.addCity(it) } } }
finish(); return
}
val timerS = intent.getIntExtra("timer_seconds", 0) val timerS = intent.getIntExtra("timer_seconds", 0)
if (timerS > 0) { if (timerS > 0) {
val id = runBlocking { Graph.repo.startTimer(timerS * 1000L, "Testtimer") } val id = runBlocking { Graph.repo.startTimer(timerS * 1000L, "Testtimer") }
+3
View File
@@ -37,6 +37,9 @@
android:turnScreenOn="true" android:turnScreenOn="true"
android:taskAffinity="" /> android:taskAffinity="" />
<activity android:name=".alarm.SunriseActivity" android:exported="false" android:excludeFromRecents="true"
android:launchMode="singleInstance" android:showWhenLocked="true" android:turnScreenOn="true" android:taskAffinity="" />
<service android:name=".alarm.SleepSoundService" android:exported="false" android:foregroundServiceType="mediaPlayback" />
<service android:name=".alarm.RingingService" android:exported="false" android:foregroundServiceType="mediaPlayback" /> <service android:name=".alarm.RingingService" android:exported="false" android:foregroundServiceType="mediaPlayback" />
<receiver android:name=".alarm.AlarmReceiver" android:exported="false" android:directBootAware="false"> <receiver android:name=".alarm.AlarmReceiver" android:exported="false" android:directBootAware="false">
@@ -164,6 +164,7 @@ private fun ClockApp(settings: Settings, requestedTab: Int) {
override fun reset(id: Long) { scope.launch { repo.resetTimer(id) } } override fun reset(id: Long) { scope.launch { repo.resetTimer(id) } }
override fun delete(id: Long) { scope.launch { repo.deleteTimer(id) } } override fun delete(id: Long) { scope.launch { repo.deleteTimer(id) } }
override fun stopRinging() { RingingService.stop(context) } override fun stopRinging() { RingingService.stop(context) }
override fun rename(id: Long, label: String) { scope.launch { repo.setTimerLabel(id, label) } }
} }
} }
@@ -209,7 +210,7 @@ private fun ClockApp(settings: Settings, requestedTab: Int) {
) { pad -> ) { pad ->
when (tab) { when (tab) {
0 -> AlarmScreen(alarms, settings, alarmActions, snackbar, pad) 0 -> AlarmScreen(alarms, settings, alarmActions, snackbar, pad)
1 -> ClockScreen(settings, cities, next, pad, onDeleteCity = { c -> scope.launch { repo.deleteCity(c.id) } }) 1 -> ClockScreen(settings, cities, next, pad, onDeleteCity = { c -> scope.launch { repo.deleteCity(c.id) } }, onReorder = { ids -> scope.launch { repo.reorderCities(ids) } })
2 -> { 2 -> {
if (keypad && timers.isNotEmpty()) BackHandler { keypad = false; digits = "" } if (keypad && timers.isNotEmpty()) BackHandler { keypad = false; digits = "" }
TimerScreen(timers, keypad, digits, { digits = it }, timerActions, pad) TimerScreen(timers, keypad, digits, { digits = it }, timerActions, pad)
@@ -31,12 +31,13 @@ class AlarmReceiver : BroadcastReceiver() {
val repo = Graph.repo val repo = Graph.repo
when (action) { when (action) {
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_LOCKED_BOOT_COMPLETED, Intent.ACTION_TIME_CHANGED, "android.intent.action.TIME_SET", Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_LOCKED_BOOT_COMPLETED, Intent.ACTION_TIME_CHANGED, "android.intent.action.TIME_SET",
Intent.ACTION_TIMEZONE_CHANGED, Intent.ACTION_MY_PACKAGE_REPLACED -> repo.scheduler.rescheduleAll() Intent.ACTION_TIMEZONE_CHANGED, Intent.ACTION_MY_PACKAGE_REPLACED -> { repo.scheduler.rescheduleAll(); repo.scheduleBedtime() }
ACTION_UPCOMING -> repo.alarm(id)?.let { a -> repo.instance(id)?.let { Notifications.upcoming(context, a, it.fireAt) } } ACTION_UPCOMING -> repo.alarm(id)?.let { a -> repo.instance(id)?.let { Notifications.upcoming(context, a, it.fireAt) } }
ACTION_DISMISS_NEXT -> repo.dismissNext(id) ACTION_DISMISS_NEXT -> repo.dismissNext(id)
ACTION_SNOOZE_DISMISS -> repo.finishAlarm(id) ACTION_SNOOZE_DISMISS -> repo.finishAlarm(id)
ACTION_TIMER_PAUSE -> repo.pauseTimer(id) ACTION_TIMER_PAUSE -> repo.pauseTimer(id)
ACTION_TIMER_ADD_MINUTE -> repo.addMinute(id) ACTION_TIMER_ADD_MINUTE -> repo.addMinute(id)
ACTION_SUNRISE -> Notifications.sunrise(context, id)
ACTION_BEDTIME -> { Notifications.bedtime(context); repo.scheduleBedtime() } ACTION_BEDTIME -> { Notifications.bedtime(context); repo.scheduleBedtime() }
} }
} }
@@ -51,6 +52,7 @@ class AlarmReceiver : BroadcastReceiver() {
const val ACTION_TIMER_PAUSE = "app.uhr.TIMER_PAUSE" const val ACTION_TIMER_PAUSE = "app.uhr.TIMER_PAUSE"
const val ACTION_TIMER_ADD_MINUTE = "app.uhr.TIMER_ADD_MINUTE" const val ACTION_TIMER_ADD_MINUTE = "app.uhr.TIMER_ADD_MINUTE"
const val ACTION_BEDTIME = "app.uhr.BEDTIME" const val ACTION_BEDTIME = "app.uhr.BEDTIME"
const val ACTION_SUNRISE = "app.uhr.SUNRISE"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
} }
} }
@@ -32,6 +32,8 @@ object Notifications {
const val ID_STOPWATCH = 2 const val ID_STOPWATCH = 2
const val ID_RINGING = 3 const val ID_RINGING = 3
const val ID_BEDTIME = 4 const val ID_BEDTIME = 4
const val ID_SLEEP = 5
const val ID_SUNRISE = 6
private const val ALARM_BASE = 1000 private const val ALARM_BASE = 1000
fun createChannels(context: Context) { fun createChannels(context: Context) {
@@ -147,6 +149,26 @@ object Notifications {
) )
} }
/** Full-screen intent is the only way to bring the sunrise screen up from the background. */
fun sunrise(context: Context, fireAt: Long) {
if (!allowed(context)) return
val full = PendingIntent.getActivity(
context, 7, Intent(context, SunriseActivity::class.java).putExtra(SunriseActivity.EXTRA_FIRE_AT, fireAt)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
// an update of an existing notification does not fire its full-screen intent again
nm(context).cancel(ID_SUNRISE)
nm(context).notify(
ID_SUNRISE,
NotificationCompat.Builder(context, CH_RINGING).setSmallIcon(R.drawable.ic_stat_alarm)
.setContentTitle(context.getString(R.string.sunrise_running)).setSilent(true)
.setCategory(NotificationCompat.CATEGORY_ALARM).setPriority(NotificationCompat.PRIORITY_MAX)
.setFullScreenIntent(full, true).setContentIntent(full).setTimeoutAfter(SunriseActivity.DURATION_MS + 60_000)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC).build(),
)
}
fun ringing(context: Context, title: String, text: String, fullScreen: PendingIntent, stop: PendingIntent, snooze: PendingIntent?): Notification = fun ringing(context: Context, title: String, text: String, fullScreen: PendingIntent, stop: PendingIntent, snooze: PendingIntent?): Notification =
NotificationCompat.Builder(context, CH_RINGING).setSmallIcon(R.drawable.ic_stat_alarm) NotificationCompat.Builder(context, CH_RINGING).setSmallIcon(R.drawable.ic_stat_alarm)
.setContentTitle(title).setContentText(text) .setContentTitle(title).setContentText(text)
@@ -71,6 +71,20 @@ class Scheduler(private val context: Context, private val dao: ClockDao) {
fun scheduleBedtimeReminder(at: Long) = fun scheduleBedtimeReminder(at: Long) =
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, broadcast(AlarmReceiver.ACTION_BEDTIME, 0, REQ_BEDTIME)) am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, broadcast(AlarmReceiver.ACTION_BEDTIME, 0, REQ_BEDTIME))
fun scheduleSunrise(fireAt: Long) {
val at = fireAt - SunriseActivity.DURATION_MS
if (at > System.currentTimeMillis()) am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, sunriseIntent(fireAt))
}
fun cancelSunrise() = am.cancel(sunriseIntent(0))
/** One sunrise at a time (fixed request code); the wake-up time travels as an extra. */
private fun sunriseIntent(fireAt: Long) = PendingIntent.getBroadcast(
context, REQ_SUNRISE,
Intent(context, AlarmReceiver::class.java).setAction(AlarmReceiver.ACTION_SUNRISE).putExtra(AlarmReceiver.EXTRA_ID, fireAt),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
fun cancelBedtimeReminder() = am.cancel(broadcast(AlarmReceiver.ACTION_BEDTIME, 0, REQ_BEDTIME)) fun cancelBedtimeReminder() = am.cancel(broadcast(AlarmReceiver.ACTION_BEDTIME, 0, REQ_BEDTIME))
private fun ringIntent(id: Long) = broadcast(AlarmReceiver.ACTION_RING, id, REQ_RING) private fun ringIntent(id: Long) = broadcast(AlarmReceiver.ACTION_RING, id, REQ_RING)
@@ -89,5 +103,6 @@ class Scheduler(private val context: Context, private val dao: ClockDao) {
private const val REQ_UPCOMING = 1_000_000 private const val REQ_UPCOMING = 1_000_000
private const val REQ_TIMER = 2_000_000 private const val REQ_TIMER = 2_000_000
private const val REQ_BEDTIME = 3_000_000 private const val REQ_BEDTIME = 3_000_000
private const val REQ_SUNRISE = 4_000_000
} }
} }
@@ -0,0 +1,109 @@
package app.uhr.alarm
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.net.Uri
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import app.uhr.MainActivity
import app.uhr.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
enum class SleepSound(val res: Int, val title: Int) {
RAIN(R.raw.sleep_rain, R.string.sound_rain),
WAVES(R.raw.sleep_waves, R.string.sound_waves),
BROWN(R.raw.sleep_brown, R.string.sound_brown),
}
data class SleepPlayback(val sound: SleepSound, val endsAt: Long)
/** Plays a looping sleep sound on the media stream and fades it out at the end of the sleep timer. */
class SleepSoundService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var player: MediaPlayer? = null
private var job: Job? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) { stop(); return START_NOT_STICKY }
val sound = SleepSound.entries.getOrNull(intent?.getIntExtra(EXTRA_SOUND, 0) ?: 0) ?: SleepSound.RAIN
val minutes = intent?.getIntExtra(EXTRA_MINUTES, 30) ?: 30
val endsAt = System.currentTimeMillis() + minutes * 60_000L
ServiceCompat.startForeground(this, Notifications.ID_SLEEP, notification(sound, endsAt), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
player?.release()
player = MediaPlayer().apply {
setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build())
setDataSource(this@SleepSoundService, Uri.parse("android.resource://$packageName/${sound.res}"))
isLooping = true
prepare()
setVolume(0f, 0f)
start()
}
state.value = SleepPlayback(sound, endsAt)
job?.cancel()
job = scope.launch {
fade(0f, 1f, 3_000)
delay((endsAt - System.currentTimeMillis() - FADE_OUT_MS).coerceAtLeast(0))
fade(1f, 0f, FADE_OUT_MS)
stop()
}
return START_NOT_STICKY
}
private suspend fun fade(from: Float, to: Float, ms: Long) {
val steps = (ms / 100).toInt().coerceAtLeast(1)
for (i in 1..steps) {
val v = from + (to - from) * i / steps
runCatching { player?.setVolume(v, v) }
delay(100)
}
}
private fun notification(sound: SleepSound, endsAt: Long) =
NotificationCompat.Builder(this, Notifications.CH_RUNNING).setSmallIcon(R.drawable.ic_stat_bedtime)
.setContentTitle(getString(sound.title)).setContentText(getString(R.string.sleep_sound_running))
.setUsesChronometer(true).setChronometerCountDown(true).setWhen(endsAt).setShowWhen(true)
.setOngoing(true).setSilent(true)
.setContentIntent(PendingIntent.getActivity(this, 5, Intent(this, MainActivity::class.java).putExtra(MainActivity.EXTRA_TAB, 4), PendingIntent.FLAG_IMMUTABLE))
.addAction(0, getString(R.string.stop), PendingIntent.getService(this, 6, Intent(this, SleepSoundService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_IMMUTABLE))
.build()
private fun stop() {
job?.cancel()
player?.runCatching { stop(); release() }; player = null
state.value = null
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() { player?.release(); state.value = null; scope.cancel(); super.onDestroy() }
companion object {
private const val ACTION_STOP = "stop"
private const val EXTRA_SOUND = "sound"
private const val EXTRA_MINUTES = "minutes"
private const val FADE_OUT_MS = 20_000L
private val state = MutableStateFlow<SleepPlayback?>(null)
val playing: StateFlow<SleepPlayback?> get() = state
fun start(context: Context, sound: SleepSound, minutes: Int) = context.startForegroundService(
Intent(context, SleepSoundService::class.java).putExtra(EXTRA_SOUND, sound.ordinal).putExtra(EXTRA_MINUTES, minutes),
)
fun stop(context: Context) = context.startService(Intent(context, SleepSoundService::class.java).setAction(ACTION_STOP))
}
}
@@ -0,0 +1,62 @@
package app.uhr.alarm
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.lerp
import app.uhr.ui.theme.WColor
import kotlinx.coroutines.delay
/**
* Sunrise alarm: in the last minutes before the wake-up alarm the screen turns on over the lock screen
* and brightens slowly from a dim red glow to warm daylight. Tap to dismiss; the alarm still rings.
*/
class SunriseActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setShowWhenLocked(true)
setTurnScreenOn(true)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
val endsAt = intent.getLongExtra(EXTRA_FIRE_AT, System.currentTimeMillis() + DURATION_MS)
val startedAt = endsAt - DURATION_MS
setContent {
val progress = remember { mutableFloatStateOf(0f) }
val ringing by RingingService.ringing.collectAsState()
LaunchedEffect(Unit) {
while (true) {
val p = ((System.currentTimeMillis() - startedAt).toFloat() / DURATION_MS).coerceIn(0f, 1f)
progress.floatValue = p
// the window brightness follows the colour, from almost off to full
window.attributes = window.attributes.apply { screenBrightness = 0.01f + 0.99f * p * p }
delay(500)
}
}
// the ringing screen takes over when the alarm starts
LaunchedEffect(ringing) { if (ringing != null) finish() }
val p = progress.floatValue
Canvas(Modifier.fillMaxSize().clickable { finish() }) {
val top = lerp(WColor.sunriseNight, WColor.sunriseDay, p)
val glow = lerp(WColor.sunriseEmber, WColor.sunriseSun, p)
drawRect(Brush.verticalGradient(listOf(top, glow)))
drawCircle(Brush.radialGradient(listOf(glow.copy(alpha = .9f), glow.copy(alpha = 0f)), Offset(size.width / 2, size.height), size.width * (0.4f + p)), size.width * (0.4f + p), Offset(size.width / 2, size.height))
}
}
}
companion object {
const val EXTRA_FIRE_AT = "fire_at"
const val DURATION_MS = 15 * 60_000L
}
}
@@ -28,10 +28,11 @@ class ClockRepo(private val context: Context, private val dao: ClockDao, val pre
suspend fun save(alarm: Alarm): Long = lock.withLock { suspend fun save(alarm: Alarm): Long = lock.withLock {
val stamped = alarm.copy(updatedAt = System.currentTimeMillis()) val stamped = alarm.copy(updatedAt = System.currentTimeMillis())
val id = if (alarm.id == 0L) dao.insert(stamped) else stamped.id.also { dao.update(stamped) } val id = if (alarm.id == 0L) dao.insert(stamped) else stamped.id.also { dao.update(stamped) }
dao.alarm(id)?.let { scheduler.reschedule(it) } val saved = dao.alarm(id)
saved?.let { scheduler.reschedule(it) }
updateClockWidgets(context) updateClockWidgets(context)
id id
} }.also { id -> if (dao.alarm(id)?.isBedtimeWake == true) scheduleBedtime() }
suspend fun delete(alarm: Alarm) = lock.withLock { suspend fun delete(alarm: Alarm) = lock.withLock {
scheduler.cancel(alarm.id) scheduler.cancel(alarm.id)
@@ -182,8 +183,15 @@ class ClockRepo(private val context: Context, private val dao: ClockDao, val pre
scheduleBedtime() scheduleBedtime()
} }
suspend fun bedtimeAlarmFireAt(): Long? {
val a = dao.bedtimeAlarm()?.takeIf { it.enabled } ?: return null
return dao.instance(a.id)?.takeIf { it.state == InstanceState.SCHEDULED }?.fireAt
}
suspend fun scheduleBedtime() { suspend fun scheduleBedtime() {
val b = prefs.bedtime.first() val b = prefs.bedtime.first()
scheduler.cancelSunrise()
if (b.enabled && b.sunrise) bedtimeAlarmFireAt()?.let { scheduler.scheduleSunrise(it) }
scheduler.cancelBedtimeReminder() scheduler.cancelBedtimeReminder()
if (!b.enabled || b.reminderMin < 0) return if (!b.enabled || b.reminderMin < 0) return
val reminder = Alarm(hour = b.bedHour, minute = b.bedMinute, days = b.days) val reminder = Alarm(hour = b.bedHour, minute = b.bedMinute, days = b.days)
+4 -2
View File
@@ -55,6 +55,7 @@ data class Bedtime(
val wakeMinute: Int = 0, val wakeMinute: Int = 0,
val days: Int = Alarm.WEEKDAYS, val days: Int = Alarm.WEEKDAYS,
val reminderMin: Int = 15, val reminderMin: Int = 15,
val sunrise: Boolean = false,
) )
private val Context.store by preferencesDataStore("clock") private val Context.store by preferencesDataStore("clock")
@@ -85,6 +86,7 @@ class Prefs(private val context: Context) {
val wakeM = intPreferencesKey("wake_m") val wakeM = intPreferencesKey("wake_m")
val bedDays = intPreferencesKey("bed_days") val bedDays = intPreferencesKey("bed_days")
val bedReminder = intPreferencesKey("bed_reminder") val bedReminder = intPreferencesKey("bed_reminder")
val sunrise = booleanPreferencesKey("sunrise")
} }
private fun Preferences.settings(): Settings { private fun Preferences.settings(): Settings {
@@ -142,14 +144,14 @@ class Prefs(private val context: Context) {
Bedtime( Bedtime(
enabled = p[K.bedOn] ?: d.enabled, bedHour = p[K.bedH] ?: d.bedHour, bedMinute = p[K.bedM] ?: d.bedMinute, enabled = p[K.bedOn] ?: d.enabled, bedHour = p[K.bedH] ?: d.bedHour, bedMinute = p[K.bedM] ?: d.bedMinute,
wakeHour = p[K.wakeH] ?: d.wakeHour, wakeMinute = p[K.wakeM] ?: d.wakeMinute, wakeHour = p[K.wakeH] ?: d.wakeHour, wakeMinute = p[K.wakeM] ?: d.wakeMinute,
days = p[K.bedDays] ?: d.days, reminderMin = p[K.bedReminder] ?: d.reminderMin, days = p[K.bedDays] ?: d.days, reminderMin = p[K.bedReminder] ?: d.reminderMin, sunrise = p[K.sunrise] ?: d.sunrise,
) )
} }
suspend fun setBedtime(b: Bedtime) { suspend fun setBedtime(b: Bedtime) {
context.store.edit { p -> context.store.edit { p ->
p[K.bedOn] = b.enabled; p[K.bedH] = b.bedHour; p[K.bedM] = b.bedMinute; p[K.wakeH] = b.wakeHour p[K.bedOn] = b.enabled; p[K.bedH] = b.bedHour; p[K.bedM] = b.bedMinute; p[K.wakeH] = b.wakeHour
p[K.wakeM] = b.wakeMinute; p[K.bedDays] = b.days; p[K.bedReminder] = b.reminderMin p[K.wakeM] = b.wakeMinute; p[K.bedDays] = b.days; p[K.bedReminder] = b.reminderMin; p[K.sunrise] = b.sunrise
} }
} }
} }
@@ -8,6 +8,9 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -36,6 +39,7 @@ import androidx.compose.material.icons.outlined.NotificationsActive
import androidx.compose.material.icons.outlined.PauseCircle import androidx.compose.material.icons.outlined.PauseCircle
import androidx.compose.material.icons.outlined.Vibration import androidx.compose.material.icons.outlined.Vibration
import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.GraphicEq
import androidx.compose.material.icons.rounded.ExpandLess import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -93,6 +97,12 @@ import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
/** One spring for everything that moves when a card opens, so size, content and neighbours stay in step. */
private val expandSpring = spring<Float>(dampingRatio = 0.9f, stiffness = 500f)
private fun androidx.compose.animation.core.SpringSpec<Float>.toIntOffset() =
spring(dampingRatio, stiffness, androidx.compose.ui.unit.IntOffset.VisibilityThreshold)
private val expandSpringSize = spring(0.9f, 500f, androidx.compose.ui.unit.IntSize.VisibilityThreshold)
/** Actions the alarm screen needs; MainActivity passes the repository, tests pass fakes. */ /** Actions the alarm screen needs; MainActivity passes the repository, tests pass fakes. */
interface AlarmActions { interface AlarmActions {
fun save(alarm: Alarm) fun save(alarm: Alarm)
@@ -131,12 +141,12 @@ fun AlarmScreen(
) { ) {
if (bedtime.isNotEmpty()) item(key = "h-bed") { SectionHeader(stringResource(R.string.wake_up)) } if (bedtime.isNotEmpty()) item(key = "h-bed") { SectionHeader(stringResource(R.string.wake_up)) }
items(bedtime, key = { it.alarm.id }) { a -> items(bedtime, key = { it.alarm.id }) { a ->
AlarmCard(a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions, onDelete = null) AlarmCard(Modifier.animateItem(placementSpec = expandSpring.toIntOffset()), a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions, onDelete = null)
} }
if (bedtime.isNotEmpty() && others.isNotEmpty()) item(key = "h-other") { SectionHeader(stringResource(R.string.other_alarms)) } if (bedtime.isNotEmpty() && others.isNotEmpty()) item(key = "h-other") { SectionHeader(stringResource(R.string.other_alarms)) }
items(others, key = { it.alarm.id }) { a -> items(others, key = { it.alarm.id }) { a ->
AlarmCard( AlarmCard(
a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions, Modifier.animateItem(placementSpec = expandSpring.toIntOffset()), a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions,
onDelete = { onDelete = {
actions.delete(a.alarm) actions.delete(a.alarm)
expanded = null expanded = null
@@ -157,6 +167,7 @@ private fun SectionHeader(text: String) =
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun AlarmCard( private fun AlarmCard(
modifier: Modifier,
item: AlarmWithInstance, item: AlarmWithInstance,
settings: Settings, settings: Settings,
expanded: Boolean, expanded: Boolean,
@@ -181,21 +192,23 @@ private fun AlarmCard(
else -> "" else -> ""
} }
Column( Column(
Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow) modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow)
.clickable(onClick = onToggleExpand).padding(WSpace.s20).animateContentSize(spring(dampingRatio = .85f, stiffness = 380f)) .clickable(onClick = onToggleExpand).padding(WSpace.s20)
.semantics(mergeDescendants = !expanded) { .semantics(mergeDescendants = !expanded) {
contentDescription = listOf(a.label, Fmt.time(a.hour, a.minute), status).filter { it.isNotBlank() }.joinToString(", ") contentDescription = listOf(a.label, Fmt.time(a.hour, a.minute), status).filter { it.isNotBlank() }.joinToString(", ")
stateDescription = res.getString(if (a.enabled) R.string.on else R.string.off) stateDescription = res.getString(if (a.enabled) R.string.on else R.string.off)
}, },
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { val rotation by androidx.compose.animation.core.animateFloatAsState(if (expanded) 180f else 0f, expandSpring, label = "chevron")
if (expanded) { Row(Modifier.height(48.dp), verticalAlignment = Alignment.CenterVertically) {
LabelField(a.label) { actions.save(a.copy(label = it)) } androidx.compose.animation.Crossfade(expanded, Modifier.weight(1f), animationSpec = tween(180), label = "label") { open ->
} else { Row(Modifier.fillMaxWidth().height(48.dp), verticalAlignment = Alignment.CenterVertically) {
Text(a.label, style = WType.label, color = scheme.onSurfaceVariant, modifier = Modifier.weight(1f)) if (open) LabelField(a.label) { actions.save(a.copy(label = it)) }
else Text(a.label, style = WType.label, color = scheme.onSurfaceVariant, modifier = Modifier.weight(1f))
}
} }
Box(Modifier.size(28.dp).clip(CircleShape).background(scheme.surfaceContainerHigh), contentAlignment = Alignment.Center) { Box(Modifier.size(28.dp).clip(CircleShape).background(scheme.surfaceContainerHigh), contentAlignment = Alignment.Center) {
Icon(if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, null, Modifier.size(20.dp), tint = scheme.onSurface) Icon(Icons.Rounded.ExpandMore, null, Modifier.size(20.dp).graphicsLayer { rotationZ = rotation }, tint = scheme.onSurface)
} }
} }
Spacer(Modifier.height(WSpace.s8)) Spacer(Modifier.height(WSpace.s8))
@@ -208,7 +221,11 @@ private fun AlarmCard(
Text(status, style = WType.bodySm, color = scheme.onSurface, modifier = Modifier.weight(1f)) Text(status, style = WType.bodySm, color = scheme.onSurface, modifier = Modifier.weight(1f))
Switch(a.enabled, onCheckedChange = { actions.save(a.copy(enabled = it, skipUntil = null)) }) Switch(a.enabled, onCheckedChange = { actions.save(a.copy(enabled = it, skipUntil = null)) })
} }
AnimatedVisibility(expanded) { AnimatedVisibility(
expanded,
enter = androidx.compose.animation.expandVertically(expandSpringSize, expandFrom = Alignment.Top) + androidx.compose.animation.fadeIn(tween(220, delayMillis = 60)),
exit = androidx.compose.animation.shrinkVertically(expandSpringSize, shrinkTowards = Alignment.Top) + androidx.compose.animation.fadeOut(tween(120)),
) {
Column { Column {
Spacer(Modifier.height(WSpace.s12)) Spacer(Modifier.height(WSpace.s12))
DayChips(a, settings.weekStart) { actions.save(it) } DayChips(a, settings.weekStart) { actions.save(it) }
@@ -247,17 +264,23 @@ private fun AlarmCard(
@Composable @Composable
private fun androidx.compose.foundation.layout.RowScope.LabelField(value: String, onDone: (String) -> Unit) { private fun androidx.compose.foundation.layout.RowScope.LabelField(value: String, onDone: (String) -> Unit) {
var text by remember(value) { mutableStateOf(value) } var text by remember(value) { mutableStateOf(value) }
TextField( androidx.compose.foundation.text.BasicTextField(
text, { text = it }, Modifier.weight(1f), text, { text = it }, Modifier.weight(1f),
placeholder = { Text(stringResource(R.string.add_label)) }, textStyle = WType.body.copy(color = MaterialTheme.colorScheme.onSurface),
leadingIcon = { Icon(Icons.Outlined.Label, null) }, cursorBrush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.primary),
singleLine = true, textStyle = WType.body, singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { onDone(text.trim()) }), keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { onDone(text.trim()) }),
colors = TextFieldDefaults.colors( decorationBox = { inner ->
focusedContainerColor = androidx.compose.ui.graphics.Color.Transparent, unfocusedContainerColor = androidx.compose.ui.graphics.Color.Transparent, Row(verticalAlignment = Alignment.CenterVertically) {
unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, Icon(Icons.Outlined.Label, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
), Spacer(Modifier.width(WSpace.s12))
Box {
if (text.isEmpty()) Text(stringResource(R.string.add_label), style = WType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
inner()
}
}
},
) )
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { if (text.trim() != value) onDone(text.trim()) } } androidx.compose.runtime.DisposableEffect(Unit) { onDispose { if (text.trim() != value) onDone(text.trim()) } }
} }
@@ -306,22 +329,49 @@ fun soundName(uri: String?): String {
} }
} }
/** S03: M3 time picker with dial/keyboard switch. */ /**
* S03: M3 time picker with dial/keyboard switch. The Compose dial has no haptics; like the original we tick
* on every value the hand passes and give a firmer click when it settles after release.
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TimeDialog(hour: Int, minute: Int, onDismiss: () -> Unit, onPick: (Int, Int) -> Unit) { fun TimeDialog(hour: Int, minute: Int, onDismiss: () -> Unit, onPick: (Int, Int) -> Unit) {
val state = rememberTimePickerState(hour, minute, Fmt.is24h) val state = rememberTimePickerState(hour, minute, Fmt.is24h)
var keyboard by remember { mutableStateOf(false) } var keyboard by remember { mutableStateOf(false) }
val view = androidx.compose.ui.platform.LocalView.current
androidx.compose.runtime.LaunchedEffect(state) {
var last = state.hour to state.minute
androidx.compose.runtime.snapshotFlow { state.hour to state.minute }.collect { now ->
if (now != last) {
view.performHapticFeedback(android.view.HapticFeedbackConstants.CLOCK_TICK)
last = now
}
}
}
// the dial switches from hours to minutes when the hour settles: confirm that with a firmer click
androidx.compose.runtime.LaunchedEffect(state) {
var hourMode = state.selection == androidx.compose.material3.TimePickerSelectionMode.Hour
androidx.compose.runtime.snapshotFlow { state.selection }.collect { sel ->
val nowHour = sel == androidx.compose.material3.TimePickerSelectionMode.Hour
if (hourMode && !nowHour) view.performHapticFeedback(android.view.HapticFeedbackConstants.CONFIRM)
hourMode = nowHour
}
}
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.pick_time)) }, title = { Text(stringResource(R.string.pick_time)) },
text = { text = {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
if (keyboard) TimeInput(state) else TimePicker(state) if (keyboard) TimeInput(state) else TimePicker(state)
TextButton(onClick = { keyboard = !keyboard }) { Text(stringResource(if (keyboard) R.string.use_dial else R.string.use_keyboard)) } TextButton(onClick = { keyboard = !keyboard }) { Text(stringResource(if (keyboard) R.string.use_dial else R.string.use_keyboard)) }
} }
}, },
confirmButton = { TextButton(onClick = { onPick(state.hour, state.minute) }) { Text(stringResource(android.R.string.ok)) } }, confirmButton = {
TextButton(onClick = {
view.performHapticFeedback(android.view.HapticFeedbackConstants.CONFIRM)
onPick(state.hour, state.minute)
}) { Text(stringResource(android.R.string.ok)) }
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } }, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
) )
} }
@@ -339,12 +389,14 @@ fun SoundDialog(current: String?, onDismiss: () -> Unit, onPick: (String?) -> Un
} }
list list
} }
var preview by remember { mutableStateOf<android.media.Ringtone?>(null) } // demo on the alarm stream, like the real ring: audible in silent mode and Do Not Disturb
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { preview?.stop() } } val preview = remember { SoundPreview(context) }
var playing by remember { mutableStateOf<String?>("none") }
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { preview.stop() } }
fun play(uri: String?) { fun play(uri: String?) {
preview?.stop() if (playing == uri && preview.isPlaying) { preview.stop(); playing = "none"; return }
val u = when (uri) { null -> Uri.parse("android.resource://${context.packageName}/${R.raw.alarm_default}"); Alarm.SILENT -> null; else -> Uri.parse(uri) } playing = uri
preview = u?.let { RingtoneManager.getRingtone(context, it) }?.also { it.play() } preview.play(uri)
} }
val file = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> val file = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) { if (uri != null) {
@@ -367,12 +419,49 @@ fun SoundDialog(current: String?, onDismiss: () -> Unit, onPick: (String?) -> Un
) { ) {
RadioButton(selected == uri, onClick = null) RadioButton(selected == uri, onClick = null)
Spacer(Modifier.width(WSpace.s12)) Spacer(Modifier.width(WSpace.s12))
Text(name, style = WType.body) Text(name, style = WType.body, modifier = Modifier.weight(1f))
if (playing == uri && uri != Alarm.SILENT) Icon(Icons.Rounded.GraphicEq, stringResource(R.string.playing), tint = MaterialTheme.colorScheme.primary)
} }
} }
} }
}, },
confirmButton = { TextButton(onClick = { onPick(selected) }) { Text(stringResource(android.R.string.ok)) } }, confirmButton = { TextButton(onClick = { preview.stop(); onPick(selected) }) { Text(stringResource(android.R.string.ok)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } }, dismissButton = { TextButton(onClick = { preview.stop(); onDismiss() }) { Text(stringResource(android.R.string.cancel)) } },
) )
} }
/** Plays a sound for a few seconds on the alarm stream at the user's alarm volume, then fades out. */
class SoundPreview(private val context: android.content.Context) {
private var player: android.media.MediaPlayer? = null
private val handler = android.os.Handler(android.os.Looper.getMainLooper())
val isPlaying: Boolean get() = runCatching { player?.isPlaying == true }.getOrDefault(false)
fun play(uri: String?) {
stop()
val u = when (uri) {
Alarm.SILENT -> return
null -> Uri.parse("android.resource://${context.packageName}/${R.raw.alarm_default}")
else -> Uri.parse(uri)
}
player = runCatching {
android.media.MediaPlayer().apply {
setAudioAttributes(android.media.AudioAttributes.Builder().setUsage(android.media.AudioAttributes.USAGE_ALARM)
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION).build())
setDataSource(context, u)
isLooping = true
prepare()
start()
}
}.getOrNull()
// a demo, not an alarm: stop after a few seconds
handler.postDelayed({ stop() }, DEMO_MS)
}
fun stop() {
handler.removeCallbacksAndMessages(null)
player?.runCatching { stop(); release() }
player = null
}
private companion object { const val DEMO_MS = 8_000L }
}
@@ -22,6 +22,14 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bedtime import androidx.compose.material.icons.outlined.Bedtime
import androidx.compose.material.icons.outlined.NotificationsActive import androidx.compose.material.icons.outlined.NotificationsActive
import androidx.compose.material.icons.outlined.WbSunny import androidx.compose.material.icons.outlined.WbSunny
import androidx.compose.material.icons.outlined.WbTwilight
import androidx.compose.material.icons.outlined.Waves
import androidx.compose.material.icons.rounded.Stop
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.platform.LocalContext
import app.uhr.alarm.SleepSound
import app.uhr.alarm.SleepSoundService
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -102,7 +110,9 @@ fun BedtimeScreen(b: Bedtime, settings: Settings, onSave: (Bedtime) -> Unit, con
{ m -> if (m < 0) stringResource(R.string.off) else if (m == 0) stringResource(R.string.at_bedtime) else stringResource(R.string.minutes_before, m) }, { m -> if (m < 0) stringResource(R.string.off) else if (m == 0) stringResource(R.string.at_bedtime) else stringResource(R.string.minutes_before, m) },
{ m -> onSave(b.copy(reminderMin = m)) }, Icons.Outlined.NotificationsActive) { m -> onSave(b.copy(reminderMin = m)) }, Icons.Outlined.NotificationsActive)
} }
item { SettingsSwitch(it, stringResource(R.string.sunrise_alarm), b.sunrise, { v -> onSave(b.copy(sunrise = v)) }, stringResource(R.string.sunrise_alarm_sub), Icons.Outlined.WbTwilight) }
} }
SleepSounds()
Spacer(Modifier.height(120.dp)) Spacer(Modifier.height(120.dp))
} }
if (pickBed) TimeDialog(b.bedHour, b.bedMinute, { pickBed = false }) { h, m -> pickBed = false; onSave(b.copy(bedHour = h, bedMinute = m)) } if (pickBed) TimeDialog(b.bedHour, b.bedMinute, { pickBed = false }) { h, m -> pickBed = false; onSave(b.copy(bedHour = h, bedMinute = m)) }
@@ -122,3 +132,29 @@ private fun TimeTile(icon: androidx.compose.ui.graphics.vector.ImageVector, labe
Text(time, style = tabular(WType.timerTime)) Text(time, style = tabular(WType.timerTime))
} }
} }
/** Own looping sounds with a sleep timer; they fade out at the end. */
@Composable
private fun SleepSounds() {
val context = LocalContext.current
val playing by SleepSoundService.playing.collectAsState()
var sound by remember { mutableStateOf(playing?.sound ?: SleepSound.RAIN) }
var minutes by remember { mutableStateOf(30) }
SettingsGroup(stringResource(R.string.sleep_sounds)) {
item {
SettingsChoice(it, stringResource(R.string.sleep_sound), SleepSound.entries, sound, { s -> stringResource(s.title) }, { s -> sound = s }, Icons.Outlined.Waves)
}
item {
SettingsChoice(it, stringResource(R.string.sleep_timer), listOf(15, 30, 45, 60, 90), minutes, { m -> stringResource(R.string.n_minutes, m) }, { m -> minutes = m }, Icons.Outlined.Bedtime)
}
item {
val on = playing != null
app.uhr.ui.components.SettingsItem(
it, stringResource(if (on) R.string.stop else R.string.sleep_play),
if (on) stringResource(R.string.sleep_ends, app.uhr.ui.components.Fmt.clock(playing!!.endsAt)) else null,
if (on) Icons.Rounded.Stop else Icons.Rounded.PlayArrow,
onClick = { if (on) SleepSoundService.stop(context) else SleepSoundService.start(context, sound, minutes) },
)
}
}
}
@@ -40,6 +40,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.zIndex
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -69,6 +73,7 @@ fun ClockScreen(
contentPadding: PaddingValues, contentPadding: PaddingValues,
onDeleteCity: (City) -> Unit, onDeleteCity: (City) -> Unit,
nowOverride: Long? = null, nowOverride: Long? = null,
onReorder: (List<Long>) -> Unit = {},
) { ) {
val tick = remember { mutableLongStateOf(nowOverride ?: System.currentTimeMillis()) } val tick = remember { mutableLongStateOf(nowOverride ?: System.currentTimeMillis()) }
if (nowOverride == null) LaunchedEffect(settings.showSeconds) { if (nowOverride == null) LaunchedEffect(settings.showSeconds) {
@@ -77,6 +82,11 @@ fun ClockScreen(
val now = tick.longValue val now = tick.longValue
val res = LocalContext.current.resources val res = LocalContext.current.resources
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val view = androidx.compose.ui.platform.LocalView.current
val density = androidx.compose.ui.platform.LocalDensity.current
var order by remember(cities) { mutableStateOf(cities) }
var draggedId by remember { mutableStateOf<Long?>(null) }
var dragOffset by remember { androidx.compose.runtime.mutableFloatStateOf(0f) }
LazyColumn( LazyColumn(
Modifier.fillMaxSize(), Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = WSpace.s16, end = WSpace.s16, top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding() + 120.dp), contentPadding = PaddingValues(start = WSpace.s16, end = WSpace.s16, top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding() + 120.dp),
@@ -101,7 +111,33 @@ fun ClockScreen(
} }
} }
} }
items(cities, key = { it.id }) { city -> CityRow(city, now, settings) { onDeleteCity(city) } } items(order, key = { it.id }) { city ->
val dragging = draggedId == city.id
Box(
Modifier.zIndex(if (dragging) 1f else 0f)
.graphicsLayer { translationY = if (dragging) dragOffset else 0f; scaleX = if (dragging) 1.03f else 1f; scaleY = scaleX; shadowElevation = if (dragging) 12f else 0f }
.then(if (dragging) Modifier else Modifier.animateItem())
.pointerInput(city.id) {
detectDragGesturesAfterLongPress(
onDragStart = { draggedId = city.id; dragOffset = 0f; view.performHapticFeedback(android.view.HapticFeedbackConstants.LONG_PRESS) },
onDragEnd = { draggedId = null; dragOffset = 0f; onReorder(order.map { it.id }) },
onDragCancel = { draggedId = null; dragOffset = 0f; order = cities },
) { change, drag ->
change.consume()
dragOffset += drag.y
// swap with the neighbour once the card has moved past half of it
val step = size.height + with(density) { WSpace.s8.toPx() }
val i = order.indexOfFirst { it.id == city.id }
val target = (i + (dragOffset / step).toInt()).coerceIn(0, order.lastIndex)
if (target != i) {
order = order.toMutableList().apply { add(target, removeAt(i)) }
dragOffset -= (target - i) * step
view.performHapticFeedback(android.view.HapticFeedbackConstants.SEGMENT_TICK)
}
}
},
) { CityRow(city, now, settings) { onDeleteCity(city) } }
}
} }
} }
@@ -11,6 +11,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.outlined.AccessTime import androidx.compose.material.icons.outlined.AccessTime
import androidx.compose.material.icons.outlined.BatteryChargingFull
import androidx.compose.material.icons.outlined.CalendarMonth import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.DarkMode import androidx.compose.material.icons.outlined.DarkMode
import androidx.compose.material.icons.outlined.Snooze import androidx.compose.material.icons.outlined.Snooze
@@ -84,6 +85,9 @@ fun SettingsScreen(s: Settings, onChange: ((Settings) -> Settings) -> Unit, onBa
SettingsChoice(it, stringResource(R.string.theme), ThemeMode.entries, s.theme, { v -> stringResource(labels.getValue(v)) }, { v -> onChange { x -> x.copy(theme = v) } }, Icons.Outlined.DarkMode) SettingsChoice(it, stringResource(R.string.theme), ThemeMode.entries, s.theme, { v -> stringResource(labels.getValue(v)) }, { v -> onChange { x -> x.copy(theme = v) } }, Icons.Outlined.DarkMode)
} }
} }
SettingsGroup(stringResource(R.string.charging)) {
item { app.uhr.ui.components.SettingsItem(it, stringResource(R.string.adaptive_charging), stringResource(R.string.adaptive_charging_text), Icons.Outlined.BatteryChargingFull) }
}
Spacer(Modifier.height(32.dp())) Spacer(Modifier.height(32.dp()))
} }
} }
@@ -39,6 +39,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -72,6 +73,7 @@ interface TimerActions {
fun reset(id: Long) fun reset(id: Long)
fun delete(id: Long) fun delete(id: Long)
fun stopRinging() fun stopRinging()
fun rename(id: Long, label: String) {}
} }
/** S08: keypad when there is no timer (or after "+"), cards for running ones. */ /** S08: keypad when there is no timer (or after "+"), cards for running ones. */
@@ -109,7 +111,9 @@ private fun TimerCard(t: Timer, now: Long, actions: TimerActions) {
val label = t.label.ifBlank { stringResource(R.string.timer_of, Fmt.duration(t.durationMs)) } val label = t.label.ifBlank { stringResource(R.string.timer_of, Fmt.duration(t.durationMs)) }
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow).padding(WSpace.s20)) { Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow).padding(WSpace.s20)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text(label, style = WType.title, modifier = Modifier.weight(1f)) var renaming by remember { androidx.compose.runtime.mutableStateOf(false) }
Text(label, style = WType.title, modifier = Modifier.weight(1f).clip(RoundedCornerShape(WRadius.xs)).clickable { renaming = true })
if (renaming) RenameDialog(t.label, { renaming = false }) { renaming = false; actions.rename(t.id, it) }
IconButton(onClick = { actions.delete(t.id) }) { Icon(Icons.Rounded.Close, stringResource(R.string.delete)) } IconButton(onClick = { actions.delete(t.id) }) { Icon(Icons.Rounded.Close, stringResource(R.string.delete)) }
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -205,3 +209,18 @@ private fun Keypad(digits: String, onDigits: (String) -> Unit, modifier: Modifie
} }
} }
} }
@Composable
private fun RenameDialog(current: String, onDismiss: () -> Unit, onSave: (String) -> Unit) {
var text by remember { androidx.compose.runtime.mutableStateOf(current) }
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.rename_timer)) },
text = {
androidx.compose.material3.OutlinedTextField(text, { text = it.take(40) }, singleLine = true,
placeholder = { Text(stringResource(R.string.add_label)) }, modifier = Modifier.fillMaxWidth())
},
confirmButton = { androidx.compose.material3.TextButton(onClick = { onSave(text.trim()) }) { Text(stringResource(android.R.string.ok)) } },
dismissButton = { androidx.compose.material3.TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
)
}
@@ -37,7 +37,8 @@ private val Dark = darkColorScheme(
) )
private val AppTypography = Typography( private val AppTypography = Typography(
displayLarge = WType.clockBig, headlineLarge = WType.headline, titleLarge = WType.title, // displayLarge/headline stay M3 defaults: the time picker uses them for its hour and minute fields
titleLarge = WType.title,
bodyLarge = WType.body, bodyMedium = WType.bodySm, labelLarge = WType.label, labelMedium = WType.label, labelSmall = WType.labelSm, bodyLarge = WType.body, bodyMedium = WType.bodySm, labelLarge = WType.label, labelMedium = WType.label, labelSmall = WType.labelSm,
) )
@@ -65,6 +65,10 @@ object WColor {
val timerProgress = Color(0xFF7EC4A8) val timerProgress = Color(0xFF7EC4A8)
val nightText = Color(0xFFC4433A) val nightText = Color(0xFFC4433A)
val nightBg = Color(0xFF000000) val nightBg = Color(0xFF000000)
val sunriseNight = Color(0xFF0D0710)
val sunriseEmber = Color(0xFF5A1A10)
val sunriseDay = Color(0xFFFFD9A0)
val sunriseSun = Color(0xFFFFF3D6)
} }
object WType { object WType {
Binary file not shown.
Binary file not shown.
Binary file not shown.
+17
View File
@@ -46,6 +46,7 @@
<string name="alarm_sound">Weckton</string> <string name="alarm_sound">Weckton</string>
<string name="sound_default">Standard (Morgenklang)</string> <string name="sound_default">Standard (Morgenklang)</string>
<string name="sound_silent">Ohne Ton</string> <string name="sound_silent">Ohne Ton</string>
<string name="playing">Wird abgespielt</string>
<string name="add_own_sound">+ Eigene Audiodatei wählen</string> <string name="add_own_sound">+ Eigene Audiodatei wählen</string>
<string name="pick_time">Uhrzeit wählen</string> <string name="pick_time">Uhrzeit wählen</string>
<string name="use_dial">Zifferblatt</string> <string name="use_dial">Zifferblatt</string>
@@ -86,6 +87,19 @@
<string name="at_bedtime">Zur Schlafenszeit</string> <string name="at_bedtime">Zur Schlafenszeit</string>
<string name="minutes_before">%1$d Min. vorher</string> <string name="minutes_before">%1$d Min. vorher</string>
<string name="sunrise_alarm">Sonnenaufgang</string>
<string name="sunrise_alarm_sub">15 Min. vor dem Aufwachen wird der Bildschirm langsam heller</string>
<string name="sunrise_running">Sonnenaufgang läuft</string>
<string name="sleep_sounds">Einschlafgeräusche</string>
<string name="sleep_sound">Geräusch</string>
<string name="sleep_timer">Ausschalten nach</string>
<string name="sleep_play">Abspielen</string>
<string name="sleep_ends">Endet um %1$s, wird leiser</string>
<string name="sleep_sound_running">Läuft, blendet am Ende aus</string>
<string name="sound_rain">Regen</string>
<string name="sound_waves">Meeresrauschen</string>
<string name="sound_brown">Tiefes Rauschen</string>
<string name="rename_timer">Timer benennen</string>
<string name="silence_after">Automatisch stumm nach</string> <string name="silence_after">Automatisch stumm nach</string>
<string name="snooze_length">Schlummerdauer</string> <string name="snooze_length">Schlummerdauer</string>
<string name="alarm_volume">Lautstärke</string> <string name="alarm_volume">Lautstärke</string>
@@ -103,6 +117,9 @@
<string name="theme_system">System</string> <string name="theme_system">System</string>
<string name="theme_light">Hell</string> <string name="theme_light">Hell</string>
<string name="theme_dark">Dunkel</string> <string name="theme_dark">Dunkel</string>
<string name="charging">Laden</string>
<string name="adaptive_charging">Adaptives Laden</string>
<string name="adaptive_charging_text">Diese App meldet jeden Wecker als „nächsten Wecker“ an das System. Ladefunktionen wie das adaptive Laden auf Pixel-Geräten richten sich danach und laden bis zum Klingeln langsam auf 100 %. Die Ladegeschwindigkeit selbst kann nur das System steuern, keine App. Auf GrapheneOS gibt es stattdessen die Ladebegrenzung auf 80 % in den Akku-Einstellungen.</string>
<string name="n_minutes">%1$d Minuten</string> <string name="n_minutes">%1$d Minuten</string>
<string name="n_seconds">%1$d Sekunden</string> <string name="n_seconds">%1$d Sekunden</string>