build(rechner): exact expression engine, keypad, scientific panel, history, memory; README

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-17 16:02:11 +02:00
co-authored by Claude Opus 5.5
parent 7ef64324fb
commit 5027fbcd85
40 changed files with 1899 additions and 30 deletions
+33 -1
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.
@@ -102,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).
@@ -120,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:
@@ -143,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/
@@ -156,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")
+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

+26 -26
View File
@@ -1,29 +1,29 @@
feature,area,priority,original,clone,notes feature,area,priority,original,clone,notes
Basic arithmetic + − × ÷,core,must,yes,no, Basic arithmetic + − × ÷,core,must,yes,yes,
Live result preview while typing,core,must,yes,no, Live result preview while typing,core,must,yes,yes,
Equals makes the result the new expression,core,must,yes,no, Equals makes the result the new expression,core,must,yes,yes,
Parentheses smart toggle key,core,must,yes,no, Parentheses smart toggle key,core,must,yes,yes,
Percent,core,must,yes,no, Percent,core,must,yes,yes,
Backspace and long-press to clear,core,must,yes,no, Backspace and long-press to clear,core,must,yes,yes,
AC clears,core,must,yes,no, AC clears,core,must,yes,yes,
Exact arithmetic (no floating point errors),core,must,yes,no,BigDecimal/rational Exact arithmetic (no floating point errors),core,must,yes,yes,BigDecimal/rational
Errors shown (division by zero domain errors),core,must,yes,no, Errors shown (division by zero domain errors),core,must,yes,yes,
Auto-shrinking display for long input,display,must,yes,no, Auto-shrinking display for long input,display,must,yes,yes,
Scientific row √ π ^ !,science,must,yes,no, Scientific row √ π ^ !,science,must,yes,yes,
Expanded panel sin cos tan ln log e,science,must,yes,no, Expanded panel sin cos tan ln log e,science,must,yes,yes,
Deg/Rad switch,science,must,yes,no, Deg/Rad switch,science,must,yes,yes,
Inv swaps to inverse functions,science,must,yes,no, Inv swaps to inverse functions,science,must,yes,yes,
Implicit multiplication (2π 3(4)),core,should,yes,no, Implicit multiplication (2π 3(4)),core,should,yes,yes,
History grouped by day,history,must,yes,no, History grouped by day,history,must,yes,yes,
Tap a history entry to reuse,history,must,yes,no, Tap a history entry to reuse,history,must,yes,yes,
Clear history,history,should,yes,no, Clear history,history,should,yes,yes,
Pull down the display to open history,history,should,yes,no, Pull down the display to open history,history,should,yes,yes,
Copy result / paste into expression,clipboard,must,yes,no, Copy result / paste into expression,clipboard,must,yes,yes,
Memory store and recall (MS MR),clipboard,should,yes,no, Memory store and recall (MS MR),clipboard,should,yes,yes,
Big numbers like 100! with scrolling,core,should,yes,no, 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,no,comma in German Locale decimal separator,display,should,yes,yes,comma in German
Expressive keys with press morph,design,must,yes,no, Expressive keys with press morph,design,must,yes,yes,
Dynamic colour (wallpaper) and dark mode,design,must,yes,no, Dynamic colour (wallpaper) and dark mode,design,must,yes,yes,
Haptic key feedback,design,should,yes,no, Haptic key feedback,design,should,yes,yes,
Tablet / landscape layout with all functions,layout,could,yes,no, Tablet / landscape layout with all functions,layout,could,yes,no,
Feedback and help,other,could,yes,skip,Google service Feedback and help,other,could,yes,skip,Google service
1 feature area priority original clone notes
2 Basic arithmetic + − × ÷ core must yes no yes
3 Live result preview while typing core must yes no yes
4 Equals makes the result the new expression core must yes no yes
5 Parentheses smart toggle key core must yes no yes
6 Percent core must yes no yes
7 Backspace and long-press to clear core must yes no yes
8 AC clears core must yes no yes
9 Exact arithmetic (no floating point errors) core must yes no yes BigDecimal/rational
10 Errors shown (division by zero domain errors) core must yes no yes
11 Auto-shrinking display for long input display must yes no yes
12 Scientific row √ π ^ ! science must yes no yes
13 Expanded panel sin cos tan ln log e science must yes no yes
14 Deg/Rad switch science must yes no yes
15 Inv swaps to inverse functions science must yes no yes
16 Implicit multiplication (2π 3(4)) core should yes no yes
17 History grouped by day history must yes no yes
18 Tap a history entry to reuse history must yes no yes
19 Clear history history should yes no yes
20 Pull down the display to open history history should yes no yes
21 Copy result / paste into expression clipboard must yes no yes
22 Memory store and recall (MS MR) clipboard should yes no yes
23 Big numbers like 100! with scrolling core should yes no partial exact, shown scientific (9,33E157); full digits not scrollable yet
24 Locale decimal separator display should yes no yes comma in German
25 Expressive keys with press morph design must yes no yes
26 Dynamic colour (wallpaper) and dark mode design must yes no yes
27 Haptic key feedback design should yes no yes
28 Tablet / landscape layout with all functions layout could yes no
29 Feedback and help other could yes skip Google service