From a9b7e0fb1d8da2b402acaa9a927f94197ab6caab Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 3 Oct 2026 12:28:47 -0700 Subject: [PATCH] initial android (ai generated) --- .gitignore | 7 + .mise.toml | 53 ++ android/README.md | 70 +++ android/SETUP.md | 230 +++++++++ android/app/build.gradle.kts | 80 +++ .../java/dev/lerch/tally/TallyEngineTest.kt | 194 +++++++ android/app/src/main/AndroidManifest.xml | 26 + .../src/main/java/dev/lerch/tally/AppPrefs.kt | 81 +++ .../main/java/dev/lerch/tally/CalcState.kt | 105 ++++ .../java/dev/lerch/tally/CalculatorScreen.kt | 392 +++++++++++++++ .../java/dev/lerch/tally/ConvertScreen.kt | 476 ++++++++++++++++++ .../java/dev/lerch/tally/ConvertViewModel.kt | 221 ++++++++ .../main/java/dev/lerch/tally/Converter.kt | 283 +++++++++++ .../src/main/java/dev/lerch/tally/Keypad.kt | 322 ++++++++++++ .../main/java/dev/lerch/tally/MainActivity.kt | 233 +++++++++ .../main/java/dev/lerch/tally/TallyEngine.kt | 229 +++++++++ .../java/dev/lerch/tally/TallyViewModel.kt | 297 +++++++++++ .../src/main/java/dev/lerch/tally/Units.kt | 113 +++++ .../res/drawable/ic_launcher_foreground.xml | 24 + .../res/drawable/ic_launcher_monochrome.xml | 15 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 8 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + .../app/src/main/res/values-night/themes.xml | 6 + .../main/res/values/ic_launcher_colors.xml | 5 + android/app/src/main/res/values/themes.xml | 6 + .../java/dev/lerch/tally/CalcCodecTest.kt | 70 +++ .../java/dev/lerch/tally/ConverterTest.kt | 249 +++++++++ android/build.gradle.kts | 13 + android/gradle.properties | 8 + android/settings.gradle.kts | 17 + build.zig | 155 +++++- 31 files changed, 3990 insertions(+), 4 deletions(-) create mode 100644 android/README.md create mode 100644 android/SETUP.md create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/src/androidTest/java/dev/lerch/tally/TallyEngineTest.kt create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/dev/lerch/tally/AppPrefs.kt create mode 100644 android/app/src/main/java/dev/lerch/tally/CalcState.kt create mode 100644 android/app/src/main/java/dev/lerch/tally/CalculatorScreen.kt create mode 100644 android/app/src/main/java/dev/lerch/tally/ConvertScreen.kt create mode 100644 android/app/src/main/java/dev/lerch/tally/ConvertViewModel.kt create mode 100644 android/app/src/main/java/dev/lerch/tally/Converter.kt create mode 100644 android/app/src/main/java/dev/lerch/tally/Keypad.kt create mode 100644 android/app/src/main/java/dev/lerch/tally/MainActivity.kt create mode 100644 android/app/src/main/java/dev/lerch/tally/TallyEngine.kt create mode 100644 android/app/src/main/java/dev/lerch/tally/TallyViewModel.kt create mode 100644 android/app/src/main/java/dev/lerch/tally/Units.kt create mode 100644 android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android/app/src/main/res/drawable/ic_launcher_monochrome.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android/app/src/main/res/values-night/themes.xml create mode 100644 android/app/src/main/res/values/ic_launcher_colors.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/app/src/test/java/dev/lerch/tally/CalcCodecTest.kt create mode 100644 android/app/src/test/java/dev/lerch/tally/ConverterTest.kt create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/settings.gradle.kts diff --git a/.gitignore b/.gitignore index fe35b4b..1298275 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,13 @@ .zig-cache/ zig-out/ +# Gradle and Android build artifacts +android/.gradle/ +android/.kotlin/ +android/build/ +android/app/build/ +android/local.properties + # Temporary files .tmp/ diff --git a/.mise.toml b/.mise.toml index bd31894..055c0ed 100644 --- a/.mise.toml +++ b/.mise.toml @@ -3,5 +3,58 @@ zig = "0.16.0" zls = "0.16.0" "github:j178/prek" = "0.4.1" +# Android. Not installed by default on a machine that only builds the CLI and TUI: run +# `mise install` in this directory to pull them when working on the app. +# +# `mise install` is necessary but not sufficient. It provides these four tools; the SDK +# packages they need are downloaded by `sdkmanager` into the android-sdk install +# directory, where mise does not track them - so they survive nothing and are reproduced +# by the tasks below rather than by this table. No NDK: the native library is built by Zig +# without one, and the JNI glue is hand-written (design 6.2, 6.4). See android/SETUP.md. +java = "temurin-21" +gradle = "8.14.5" +android-sdk = "23.0" + [tools."github:DonIsaac/zlint"] version = "0.9.0" + +# -- Android SDK packages -- +# +# The versioned ones are pinned; `platform-tools` and `emulator` have no version in their +# package id and always resolve to current, which is the one thing here that is not +# reproducible. Both tasks are idempotent: an already-installed package is a no-op. + +[tasks.android-sdk] +description = "Install the SDK packages needed to build the app (~450MB)" +run = 'sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"' + +[tasks.android-emulator] +description = "Install the emulator and a 16KB-page system image (~4.6GB)" +run = 'sdkmanager "emulator" "system-images;android-35;google_apis_ps16k;x86_64"' + +[tasks.android-avd] +description = "Create the tally35 AVD in .tmp/avd - recreating it wipes installed apps" +run = ''' +mkdir -p "$MISE_PROJECT_ROOT/.tmp/avd" +ANDROID_AVD_HOME="$MISE_PROJECT_ROOT/.tmp/avd" avdmanager create avd \ + --name tally35 --device pixel_6 --force \ + --package "system-images;android-35;google_apis_ps16k;x86_64" +''' + +[tasks.android-install] +description = "Build the native libs and APK, install on the attached device, launch it" +# Rebuilds the libraries every time on purpose: Gradle never invokes Zig, so skipping +# this step packages a stale libtally.so without complaint. +run = ''' +set -e +zig build android +(cd "$MISE_PROJECT_ROOT/android" && gradle --no-daemon assembleDebug) +adb wait-for-device +until [ "$(adb shell getprop sys.boot_completed | tr -d '\r')" = "1" ]; do sleep 2; done +adb install -r "$MISE_PROJECT_ROOT/android/app/build/outputs/apk/debug/app-debug.apk" +adb shell am start -n dev.lerch.tally/.MainActivity +''' + +[tasks.android-run] +description = "Boot the tally35 AVD - does not install the app (add -- -no-window for headless)" +run = 'ANDROID_AVD_HOME="$MISE_PROJECT_ROOT/.tmp/avd" emulator -avd tally35 -no-audio -no-boot-anim -gpu swiftshader_indirect' diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..28d4aa7 --- /dev/null +++ b/android/README.md @@ -0,0 +1,70 @@ +# Tally for Android + +Two screens, reached from a hamburger drawer: the standard calculator (design 9.2), an +NCalc-style pad with a live answer as you type, and the converter (design 9.6), which +reopens where it was left and shows a value in every unit at once. Settings has the +theme: follow the device, light or dark. The engine behind it is the finished one, so +`2^100 + 1`, `x = 5` then `x * 2` and `cagr(10000, 25000, 5) * 100` all work. The +programmer and financial screens (design 9) are not built. + +## Building + +Nothing is installed system-wide; the toolchain comes from mise. **Setup, emulator and +deployment, including wireless adb, are in [SETUP.md](SETUP.md)** - this file is about what +the app is. + +``` +mise install && mise run android-sdk # toolchain, then the SDK packages +zig build android # the native library, one .so per ABI +cd android && gradle assembleDebug +``` + +`zig build android` writes `zig-out/android/{arm64-v8a,x86_64,armeabi-v7a}/libtally.so`, +which is the `jniLibs` layout Gradle expects, so Gradle only packages it. Gradle never +invokes Zig, and there is no CMake or `ndk-build` in this project: the library is built +without the NDK entirely (design 6.2), and the JNI glue is hand-written Zig +(`engine/src/jni.zig`) rather than generated from `jni.h`. + +Because Gradle never invokes Zig, `zig build android` has to be re-run by hand after any +engine or JNI change, or the APK gets a stale library. + +The Gradle wrapper is not committed; `gradle wrapper` writes one, or Android Studio offers +to on first open. + +## The first thing to run, and it needs no device + +``` +zig build jvm-test +``` + +`engine/src/jni.zig` reaches the JNI function table by index, because there is no `jni.h` +here to name the entries. Four of those indices have to be right. JNI's table is fixed by +the specification rather than by the platform, so a desktop JVM checks them just as well as +a phone: this step builds the library for the host, loads it from Java (which runs +`JNI_OnLoad`, which round-trips a string through two of the four), and drives every entry +point. Moving one index by a slot makes it fail. + +Then, with a device or emulator attached: + +``` +cd android && gradle connectedAndroidTest +``` + +`TallyEngineTest` covers what only the real app can: that a session remembers `x` and +`Ans`, that `2^100 + 1` arrives with its last digit intact, that `12 in to ft` is exactly +`1`, that an error carries the engine's own wording rather than a second copy written in +Kotlin, and that a closed session refuses to evaluate instead of crashing. It passes on an +Android 15 x86_64 image with 16KB pages. Note that it uninstalls the app when it finishes. + +Nothing has been verified on a physical device yet, and arm64-v8a is the interesting one: +it is the ABI phones actually use, and the only one where the page-size bug in design 6.3 +showed up. + +## What is deliberately not here + +- **No arithmetic.** Kotlin parses no expressions, formats no numbers and words no errors. + All of it is the shared engine, so a wrong answer is wrong in the CLI and TUI too, which + is the point of having one engine. +- **No error table.** Messages come from `engine.phrase` through the JSON. +- **No unit list.** `TallySession.unitCatalog()` returns the engine's own tables, aliases + included, for whenever the convert screen is built. diff --git a/android/SETUP.md b/android/SETUP.md new file mode 100644 index 0000000..1f372ca --- /dev/null +++ b/android/SETUP.md @@ -0,0 +1,230 @@ +# Android setup, emulator and deployment + +Everything here is per-user and removable. Nothing is installed system-wide, no package +manager outside mise is involved, and no NDK is needed at any point. + +## What installs what + +It is not mise alone, and the distinction matters when something breaks. + +| Layer | Provided by | Pinned? | Lives in | +|---|---|---|---| +| Zig, JDK, Gradle, SDK command-line tools | mise, from `.mise.toml` | yes, by version | `~/.local/share/mise/installs/` | +| SDK packages: platforms, build-tools, platform-tools, emulator, system images | `sdkmanager`, via the mise tasks below | partly - see below | inside the mise `android-sdk` install directory | +| The AVD (the emulator's virtual device) | `avdmanager`, via a mise task | n/a | `.tmp/avd` in this repo, gitignored | + +`mise install` is necessary but not sufficient. It gives you `sdkmanager`; it does not give +you an SDK you can build against. mise has no idea the SDK packages exist, so they are not +captured by `mise install` on another machine and are lost if the `android-sdk` tool is +reinstalled. The four tasks below exist to make that step repeatable, and they are the +answer to "is it mise only": it is now, in the sense that every command is a mise task, +but the pinning is weaker than the `[tools]` table. + +`platforms;android-35`, `build-tools;35.0.0` and the system image are pinned by exact +version. `platform-tools` and `emulator` have no version component in their package ids +and always resolve to whatever is current - the one genuinely unpinned thing here. + +Disk cost, which is not small: about 450MB for the build packages, and about 4.6GB more if +you want the emulator (821MB emulator, 3.8GB system image). + +## One-time setup + +``` +mise install # Zig, JDK 21, Gradle 8.14.5, SDK command-line tools +mise run android-sdk # platform-tools, platforms;android-35, build-tools;35.0.0 +``` + +Add the emulator only if you want one; a physical device needs neither of these: + +``` +mise run android-emulator # emulator + 16KB-page system image, ~4.6GB +mise run android-avd # creates the tally35 AVD in .tmp/avd +``` + +The system image is `system-images;android-35;google_apis_ps16k;x86_64`, chosen on +purpose. `ps16k` means 16KB memory pages, which Android 15 introduced and which +`zig build android` aligns the shared libraries for. It is the stricter target: 16KB +alignment is also valid on a 4KB device, so a library that loads there loads anywhere. +A libc-free library is more likely to break on it than on anything else, which is the +point. + +## Build + +``` +zig build android # native libs, one .so per ABI, into zig-out/android +cd android && gradle assembleDebug # packages them into the APK +``` + +The APK lands at `android/app/build/outputs/apk/debug/app-debug.apk`. + +Gradle never invokes Zig. `jniLibs.srcDirs` points at `zig-out/android`, so Gradle only +packages whatever is sitting there. **After changing engine or JNI code you must run +`zig build android` yourself**, or Gradle will cheerfully package a stale library and the +app will run old arithmetic. + +## Run it in the emulator + +Two steps, because booting the emulator and installing the app are separate. A freshly +booted AVD has no Tally on it. + +``` +mise run android-run & # boots tally35; `adb emu kill` to stop +mise run android-install # builds libs + APK, waits for boot, installs, launches +``` + +`android-install` re-runs `zig build android` every time on purpose, so the APK never +carries a stale library. It waits for `sys.boot_completed`, so it is safe to start while +the emulator is still booting. It works the same against a phone. + +The app goes missing in two situations, both expected: + +- `mise run android-avd` recreates the AVD with `--force`, which wipes its storage. +- `gradle connectedAndroidTest` uninstalls the app when it finishes (AGP's default). + +Either way, `mise run android-install` puts it back. + +The manual equivalent, if you want the pieces: + +``` +adb wait-for-device +adb shell getprop sys.boot_completed # wait until this prints 1 +adb install -r android/app/build/outputs/apk/debug/app-debug.apk +adb shell am start -n dev.lerch.tally/.MainActivity +``` + +For a headless run - useful over ssh, or when you only want the instrumented tests: + +``` +mise run android-run -- -no-window +``` + +Hardware acceleration needs `/dev/kvm` readable, which in practice means being in the +`kvm` group (`id -nG | grep kvm`). Without it the emulator falls back to software +rendering and is unusably slow rather than broken. + +If you run the emulator outside the mise task, export the AVD location first or it will +not find `tally35`, because it is deliberately not in `~/.android`: + +``` +export ANDROID_AVD_HOME="$PWD/.tmp/avd" +``` + +## Deploy to a phone over USB + +On the phone: Settings > About phone > tap **Build number** seven times, then +Settings > System > Developer options > **USB debugging** on. + +``` +adb devices # the phone appears; accept the prompt on its screen +mise run android-install # or: adb install -r && adb shell am start ... +``` + +Until you accept the "Allow USB debugging?" prompt on the phone, `adb devices` lists it as +`unauthorized` and every other command fails. The APK is signed with the local debug +keystore (`~/.android/debug.keystore`), which is fine for sideloading onto your own device +and not for distribution. + +## Deploy to a phone over Wi-Fi + +Yes, and there are two mechanisms. The modern one needs no cable at all. + +### Wireless debugging, Android 11 and later + +On the phone: Developer options > **Wireless debugging** > on. Tap +**Pair device with pairing code**. It shows a six-digit code and an +`ip:port` for *pairing* - note that this port is different from the one on the main +Wireless debugging screen, which is the one you connect to afterwards. + +``` +adb pair 192.168.1.50:37419 # the pairing ip:port; it prompts for the code +adb connect 192.168.1.50:42133 # the ip:port from the Wireless debugging screen +adb devices # shows 192.168.1.50:42133 device +``` + +Then `install` and `am start` exactly as over USB. Pairing is remembered, so later +sessions need only `adb connect`; the port changes on reboot, so re-read it from the +phone. `adb disconnect` when finished. + +`adb pair` exists in platform-tools 37.0.1, which is what `mise run android-sdk` +installs. Verify with `adb --help | grep pair` if in doubt. + +### adb over TCP, any version, needs one cable first + +``` +adb tcpip 5555 # over USB; restarts adbd listening on TCP + # unplug the cable now +adb connect 192.168.1.50:5555 +``` + +This is the older path and it is worth knowing what it gives up: it is unauthenticated +beyond the existing adb key and stays open on a fixed port until the phone reboots or you +run `adb usb`. On a network you do not control, prefer `adb pair`. + +Both paths need the phone and the computer on the same network, with client isolation off +- many guest networks and some mesh setups block the connection silently. + +## Tests + +``` +zig build test # 965 host tests, including the C ABI +zig build jvm-test # proves the JNI table against a desktop JVM, no device +cd android && gradle connectedAndroidTest # 10 instrumented tests, needs a device or emulator +``` + +`gradle connectedAndroidTest` **uninstalls both APKs when it finishes**, which is AGP's +default. Reinstall before launching the app by hand, or you get +`Activity class ... does not exist`. + +## When it goes wrong + +**`Error: An error occurred while creating AVD: ~/.android/avd`** - on this machine +`~/.android/avd` is a dangling symlink into a Flatpak Android Studio config directory that +no longer exists. `avdmanager` follows it, fails, and reports the path with no explanation. +The `android-avd` task sidesteps it with `ANDROID_AVD_HOME`; deleting the dead symlink +also works. + +**`UnsatisfiedLinkError: dlopen failed: cannot locate symbol ...`** - the native library +referenced something Bionic provides and this library does not link. See design 6.3; the +audit is zero `DT_NEEDED`, zero undefined dynamic symbols, no TLS segment, per ABI: + +``` +readelf -d zig-out/android/arm64-v8a/libtally.so | grep NEEDED # expect nothing +readelf --dyn-syms -W zig-out/android/arm64-v8a/libtally.so | awk '$7=="UND"' +``` + +Check all three ABIs. One instance of this reproduced on arm64-v8a alone, so an x86_64 +emulator passed while phones would have failed. + +**The app runs but answers look stale** - `zig build android` was not re-run, so the APK +carries an old `libtally.so`. + +**Nothing is wrong but you want to know the boundary is alive** - the title bar reads +`Tally 0.1.0`, and that string comes from `tally_version` through JNI. If the title +renders, the library loaded and a string round-tripped. + +Logs: + +``` +adb logcat -d | grep -iE 'tally|UnsatisfiedLink|FATAL' +``` + +Which library a device actually loaded, and whether it has 16KB pages: + +``` +adb shell getprop ro.product.cpu.abi +adb shell getconf PAGE_SIZE +``` + +## Removing all of it + +``` +adb emu kill # stop a running emulator +rm -rf .tmp/avd # the virtual device +mise run android-emulator --help # (no uninstall task; use sdkmanager directly) +sdkmanager --uninstall "emulator" "system-images;android-35;google_apis_ps16k;x86_64" +mise uninstall java gradle android-sdk # the tools themselves +``` + +Nothing outside `~/.local/share/mise`, `~/.android` and this repo's `.tmp/` is touched. +`~/.android` holds the adb key and debug keystore and is shared with any other Android +tooling on the machine, so it is left alone. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..d134e64 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,80 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "dev.lerch.tally" + compileSdk = 35 + + defaultConfig { + applicationId = "dev.lerch.tally" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + // Only the ABIs `zig build android` produces. Anything else would package an + // empty folder and fail at load rather than at build. + ndk { + abiFilters += listOf("arm64-v8a", "x86_64", "armeabi-v7a") + } + } + + // The native library comes from `zig build android`, which writes the jniLibs layout + // directly. Gradle only packages it: it never invokes Zig, and there is no CMake or + // ndk-build here on purpose (design 1). + sourceSets { + getByName("main") { + jniLibs.srcDirs("../../zig-out/android") + } + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } + } + + buildTypes { + release { + // Nothing to shrink yet; the engine is native and the Kotlin is one screen. + isMinifyEnabled = false + } + } + + packaging { + // The library is already ReleaseSmall and stripped by Zig. + jniLibs.keepDebugSymbols += "**/libtally.so" + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.10.01") + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.material3:material3") + + // The converter's rules are plain Kotlin and run on the JVM: `gradle testDebugUnitTest`, + // no device. Already in the Gradle cache as a dependency of androidx.test.ext:junit. + testImplementation("junit:junit:4.13.2") + + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test:runner:1.6.2") +} diff --git a/android/app/src/androidTest/java/dev/lerch/tally/TallyEngineTest.kt b/android/app/src/androidTest/java/dev/lerch/tally/TallyEngineTest.kt new file mode 100644 index 0000000..b902450 --- /dev/null +++ b/android/app/src/androidTest/java/dev/lerch/tally/TallyEngineTest.kt @@ -0,0 +1,194 @@ +package dev.lerch.tally + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The tests that need a real JVM, which is the half no desktop run can cover. + * + * Loading the library runs `JNI_OnLoad`, which proves the hand-written JNI function table + * against this runtime (see `engine/src/jni.zig`). If the indices in that file are wrong, + * every test here fails at the `System.loadLibrary` in [TallyEngine], which is the whole + * reason the check exists. + * + * Run with: `gradle connectedAndroidTest` (a device or emulator must be attached). + */ +@RunWith(AndroidJUnit4::class) +class TallyEngineTest { + + @Test + fun libraryLoadsAndAbiMatches() { + assertEquals(TallyEngine.EXPECTED_ABI_VERSION, TallyEngine.abiVersion()) + assertNotNull(TallyEngine.versionString()) + } + + @Test + fun sessionRemembersAcrossCalls() { + TallySession().use { session -> + // The reason the API is a session and not a function. Without a handle the + // second call could not see `x` and the third would have no `Ans`. + assertTrue(session.eval("x = 21").ok) + assertEquals("42", session.eval("x * 2").display) + assertEquals("43", session.eval("Ans + 1").display) + } + } + + @Test + fun exactArithmeticSurvivesTheBoundary() { + TallySession().use { session -> + // 2^100 has no f64 representation with its last digit intact, so this is the + // exact tier reaching Kotlin rather than a float dressed up as a string. + val result = session.eval("2^100 + 1") + assertEquals("1,267,650,600,228,229,401,496,703,205,377", result.display) + assertTrue(result.exact) + + // And half of an odd number stays a fraction rather than rounding. + assertEquals("633,825,300,114,114,700,748,351,602,688.5", session.eval("Ans / 2").display) + } + } + + @Test + fun aPreviewAnswersAndStoresNothing() { + TallySession().use { session -> + // What the live result does on every keystroke of `x = 7`. + assertEquals("7", session.preview("x = 7").display) + assertEquals("unknown variable", session.eval("x").error) + + // And `Ans` stays the last thing committed with `=`. + assertEquals("42", session.eval("6 * 7").display) + assertEquals("43", session.preview("Ans + 1").display) + assertEquals("42", session.eval("Ans").display) + } + } + + @Test + fun aValueComesBackInEveryUnitOfItsCategory() { + TallySession().use { session -> + val table = session.convert("100", "C") + assertTrue(table.ok) + assertEquals("temperature", table.category) + val f = table.rows.single { it.unit == "F" } + assertEquals("212", f.display) + assertTrue(f.exact) + assertEquals("373.15", table.rows.single { it.unit == "K" }.display) + + // The catalogue names a unit the converter accepts, and the display name the + // screen shows is the engine's own, not guessed from aliases. + val units = UnitCatalog.parse(session.unitCatalog()).flatMap { it.units } + assertEquals("Celsius", units.single { it.name == "C" }.longName) + assertEquals("Pound", units.single { it.name == "lb" }.longName) + assertEquals("Meter per second", units.single { it.name == "m/s" }.longName) + assertTrue("every unit has one", units.all { it.longName.isNotEmpty() }) + + assertEquals("unknown unit", session.convert("1", "smoot").error) + } + } + + @Test + fun aSessionSavesAndLoadsExactly() { + val state = TallySession().use { session -> + assertTrue(session.eval("x = 2^100").ok) + assertTrue(session.eval("1/3").ok) + session.save()!! + } + // A new session, as after the app was killed: variables and Ans come back. + TallySession().use { session -> + assertTrue(session.load(state)) + assertEquals("1,267,650,600,228,229,401,496,703,205,377", session.eval("x + 1").display) + } + TallySession().use { session -> + assertTrue(session.load(state)) + val third = session.eval("Ans * 3") + assertEquals("1", third.display) + assertTrue(third.exact) + assertTrue("a refused load changes nothing", !session.load("{}")) + } + } + + @Test + fun errorsCarryTheEnginesOwnWording() { + TallySession().use { session -> + val result = session.eval("1 / 0") + assertTrue(!result.ok) + // The same sentence the CLI prints. Kotlin has no error table of its own. + assertEquals("division by zero", result.error) + + // And the session is still usable afterwards. + assertEquals("6", session.eval("2 * 3").display) + } + } + + @Test + fun unitConversionWorksFromTheOneTextField() { + TallySession().use { session -> + val result = session.eval("100 km to mi") + assertTrue(result.ok) + assertEquals("km", result.conversion?.from) + assertEquals("mi", result.conversion?.to) + + // An exact conversion is exactly one foot, not 0.9999999999999998. + val exact = session.eval("12 in to ft") + assertEquals("1", exact.display) + assertTrue(exact.exact) + } + } + + @Test + fun financialFunctionsAreReachableAsExpressions() { + TallySession().use { session -> + // FR-5.7: no financial mode needed for the calculations themselves. + val result = session.eval("cagr(10000, 25000, 5) * 100") + assertTrue(result.ok) + assertTrue(result.display!!.startsWith("20.11")) + } + } + + @Test + fun nonDecimalLiteralsBringTheOtherBases() { + TallySession().use { session -> + val result = session.eval("0xFF + 1") + assertEquals("256", result.display) + assertEquals("01 00", result.bases?.hex) + assertEquals("0000 0001 0000 0000", result.bases?.bin) + } + } + + @Test + fun programmerModeReturnsEveryRow() { + TallySession().use { session -> + val result = session.eval("0xFF and 0x0F", TallyEngine.MODE_PROGRAMMER) + assertTrue(result.ok) + assertEquals("15", result.rows?.decUnsigned) + assertEquals(64, result.bits) + } + } + + @Test + fun unitCatalogComesFromTheEngine() { + TallySession().use { session -> + val json = session.unitCatalog() + assertNotNull(json) + // Enough to fill a picker without retyping the engine's tables. + assertTrue(json!!.contains("\"base_unit\":\"m\"")) + assertTrue(json.contains("kilometer")) + } + } + + @Test + fun aClosedSessionIsNotUsedAgain() { + val session = TallySession() + session.close() + // Closing twice is allowed, so a teardown path needs no bookkeeping. + session.close() + try { + session.eval("2 + 2") + throw AssertionError("a closed session should refuse to evaluate") + } catch (expected: IllegalStateException) { + // The point: a use-after-free is a Kotlin exception, not a native crash. + } + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..dccbd21 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/dev/lerch/tally/AppPrefs.kt b/android/app/src/main/java/dev/lerch/tally/AppPrefs.kt new file mode 100644 index 0000000..806824c --- /dev/null +++ b/android/app/src/main/java/dev/lerch/tally/AppPrefs.kt @@ -0,0 +1,81 @@ +package dev.lerch.tally + +import android.content.Context + +/** Light, dark, or whatever the device says - which is the default. */ +internal enum class ThemeChoice { System, Light, Dark } + +/** The screens the app opens on. Settings is reachable but never reopened at launch. */ +internal enum class Screen { Calculator, Convert, Settings } + +/** + * What survives the app being closed: the theme, the screen it was on, and the + * converter exactly as it was left. + * + * `SharedPreferences` rather than DataStore: a handful of small values, written on the + * main thread with `apply()` (which is asynchronous), and no dependency to add for it. + */ +internal class AppPrefs(context: Context) { + private val prefs = context.getSharedPreferences("tally", Context.MODE_PRIVATE) + + var theme: ThemeChoice + get() = enumOr(prefs.getString(KEY_THEME, null), ThemeChoice.System) + set(value) = prefs.edit().putString(KEY_THEME, value.name).apply() + + var screen: Screen + get() = enumOr(prefs.getString(KEY_SCREEN, null), Screen.Calculator) + .let { if (it == Screen.Settings) Screen.Calculator else it } + set(value) = prefs.edit().putString(KEY_SCREEN, value.name).apply() + + /** The converter's whole state, as [Converter.serialize] writes it. */ + var converter: String? + get() = prefs.getString(KEY_CONVERTER, null) + set(value) = prefs.edit().putString(KEY_CONVERTER, value).apply() + + // -- The calculator (design 9.2), each part as [CalcCodec] writes it -- + + /** The engine's variables and `Ans`, as `tally_session_save` wrote them. */ + var calcEngine: String? + get() = prefs.getString(KEY_CALC_ENGINE, null) + set(value) = prefs.edit().putString(KEY_CALC_ENGINE, value).apply() + + var calcHistory: String? + get() = prefs.getString(KEY_CALC_HISTORY, null) + set(value) = prefs.edit().putString(KEY_CALC_HISTORY, value).apply() + + /** The entry on display after `=`, or null when the display holds an expression. */ + var calcCommitted: String? + get() = prefs.getString(KEY_CALC_COMMITTED, null) + set(value) = prefs.edit().putString(KEY_CALC_COMMITTED, value).apply() + + var calcField: String? + get() = prefs.getString(KEY_CALC_FIELD, null) + set(value) = prefs.edit().putString(KEY_CALC_FIELD, value).apply() + + /** + * What builds before per-category memory saved: one value, its unit and the shown + * unit. Read once, to migrate; never written. + */ + val legacyConverter: Triple + get() = Triple( + prefs.getString(KEY_CONVERT_VALUE, null), + prefs.getString(KEY_CONVERT_SOURCE_UNIT, null) ?: prefs.getString(KEY_CONVERT_UNIT, null), + prefs.getString(KEY_CONVERT_UNIT, null), + ) + + private inline fun > enumOr(name: String?, fallback: E): E = + enumValues().firstOrNull { it.name == name } ?: fallback + + private companion object { + const val KEY_THEME = "theme" + const val KEY_SCREEN = "screen" + const val KEY_CONVERTER = "converter" + const val KEY_CALC_ENGINE = "calc.engine" + const val KEY_CALC_HISTORY = "calc.history" + const val KEY_CALC_COMMITTED = "calc.committed" + const val KEY_CALC_FIELD = "calc.field" + const val KEY_CONVERT_VALUE = "convert.value" + const val KEY_CONVERT_UNIT = "convert.unit" + const val KEY_CONVERT_SOURCE_UNIT = "convert.source" + } +} diff --git a/android/app/src/main/java/dev/lerch/tally/CalcState.kt b/android/app/src/main/java/dev/lerch/tally/CalcState.kt new file mode 100644 index 0000000..2525685 --- /dev/null +++ b/android/app/src/main/java/dev/lerch/tally/CalcState.kt @@ -0,0 +1,105 @@ +package dev.lerch.tally + +/** + * How the calculator's screen state is written down, so it reopens as it was left + * (design 9.2): the expression and its cursor, the answer on display, and the history. + * + * The engine's half - variables and `Ans` - is saved by the engine itself + * (`tally_session_save`, design 6.7), exactly, and is not here. This is only what the + * screen shows, and it is plain Kotlin so its rules run as JVM unit tests. + * + * A history entry keeps the engine's result JSON as it came back, rather than a Kotlin + * re-encoding of it: restoring one is parsing it again, the same way it was parsed the + * first time, so a restored entry cannot drift from a fresh one. + */ +internal data class SavedEntry(val expression: String, val resultJson: String) + +internal data class SavedField(val text: String, val selectionStart: Int, val selectionEnd: Int) + +internal object CalcCodec { + /** + * Oldest entries beyond this are dropped when saving. History is for reuse, and the + * engine keeps variables and `Ans` on its own, so trimming it loses nothing that + * later answers depend on. + */ + const val MAX_HISTORY = 500 + + private const val ENTRIES = "entries v1" + private const val FIELD = "field v1" + + fun encodeEntries(entries: List): String = buildString { + append(ENTRIES).append('\n') + for (entry in entries.takeLast(MAX_HISTORY)) { + append(escape(entry.expression)).append('\t').append(escape(entry.resultJson)).append('\n') + } + } + + /** Empty for anything that is not a saved list: a fresh start, not a guess. */ + fun decodeEntries(text: String?): List { + if (text == null) return emptyList() + val lines = text.split('\n') + if (lines.firstOrNull() != ENTRIES) return emptyList() + return lines.drop(1).filter { it.isNotEmpty() }.mapNotNull { line -> + val fields = line.split('\t') + if (fields.size != 2) return@mapNotNull null + SavedEntry(unescape(fields[0]) ?: return@mapNotNull null, unescape(fields[1]) ?: return@mapNotNull null) + } + } + + fun encodeEntry(entry: SavedEntry?): String? = entry?.let { encodeEntries(listOf(it)) } + + fun decodeEntry(text: String?): SavedEntry? = decodeEntries(text).singleOrNull() + + fun encodeField(field: SavedField): String = + "$FIELD\t${field.selectionStart}\t${field.selectionEnd}\t${escape(field.text)}" + + /** The cursor is clamped into the text, so a damaged save cannot place it outside. */ + fun decodeField(text: String?): SavedField? { + if (text == null) return null + val fields = text.split('\t') + if (fields.size != 4 || fields[0] != FIELD) return null + val value = unescape(fields[3]) ?: return null + val start = fields[1].toIntOrNull()?.coerceIn(0, value.length) ?: return null + val end = fields[2].toIntOrNull()?.coerceIn(0, value.length) ?: return null + return SavedField(value, start, end) + } + + /** Tabs and newlines are the format's separators, so they are escaped in the text. */ + internal fun escape(text: String): String = buildString(text.length) { + for (c in text) { + when (c) { + '\\' -> append("\\\\") + '\t' -> append("\\t") + '\n' -> append("\\n") + '\r' -> append("\\r") + else -> append(c) + } + } + } + + /** Null for an escape this format never writes. */ + internal fun unescape(text: String): String? { + val out = StringBuilder(text.length) + var i = 0 + while (i < text.length) { + val c = text[i] + if (c != '\\') { + out.append(c) + i += 1 + continue + } + if (i + 1 >= text.length) return null + out.append( + when (text[i + 1]) { + '\\' -> '\\' + 't' -> '\t' + 'n' -> '\n' + 'r' -> '\r' + else -> return null + }, + ) + i += 2 + } + return out.toString() + } +} diff --git a/android/app/src/main/java/dev/lerch/tally/CalculatorScreen.kt b/android/app/src/main/java/dev/lerch/tally/CalculatorScreen.kt new file mode 100644 index 0000000..cd094c8 --- /dev/null +++ b/android/app/src/main/java/dev/lerch/tally/CalculatorScreen.kt @@ -0,0 +1,392 @@ +package dev.lerch.tally + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.InterceptPlatformTextInput +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.awaitCancellation + +/** + * The standard calculator (design 9.2), shaped after NCalc: a display card over a pad, + * no system keyboard unless asked for, and the answer visible before `=` is pressed. + * + * The first version of this screen was a text field, the system keyboard and a + * scrollback - the TUI moved onto glass. Everything here is a reaction to how unusable + * that turned out to be. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun CalculatorScreen( + onMenu: () -> Unit, + onConvert: () -> Unit, + model: TallyViewModel = viewModel(), +) { + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + val keyboard = LocalSoftwareKeyboardController.current + + // The field keeps focus throughout, so it always has a cursor to insert at. Whether + // focus brings up the system keyboard is decided by `KeyboardGate`; changing that + // decision takes a fresh focus, because the gate is consulted when input starts. + LaunchedEffect(model.typing, model.showingHistory) { + if (model.showingHistory) return@LaunchedEffect + focusManager.clearFocus() + focusRequester.requestFocus() + if (model.typing) keyboard?.show() else keyboard?.hide() + } + + // The keyboard can be dismissed by the system's back gesture, which the app never + // sees. The inset is how it finds out, and the pad comes back. + val imeVisible = WindowInsets.isImeVisible + var imeWasShown by remember { mutableStateOf(false) } + LaunchedEffect(imeVisible, model.typing) { + if (!model.typing) { + imeWasShown = false + } else if (imeVisible) { + imeWasShown = true + } else if (imeWasShown) { + imeWasShown = false + model.stopTyping() + } + } + + BackHandler(enabled = model.showingHistory) { model.closeHistory() } + + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + ) { + if (model.showingHistory) { + HistoryScreen(model) + return@Box + } + KeyboardGate(allow = model.typing) { + Column(modifier = Modifier.fillMaxSize()) { + TopRow(title = "Tally", onMenu = onMenu) { + TextButton(onClick = onConvert) { Text("Convert") } + TextButton(onClick = model::openHistory) { Text("History") } + } + Display( + model = model, + focusRequester = focusRequester, + modifier = Modifier.fillMaxWidth().weight(2f), + ) + AnimatedVisibility(visible = !model.typing, modifier = Modifier.weight(5f)) { + Keypad( + onAction = { action -> handle(model, action) }, + onStore = model::storeInto, + onClearAll = model::clear, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } +} + +private fun handle(model: TallyViewModel, action: Action) { + when (action) { + is Action.Insert -> model.insert(action.text, action.continues) + is Action.Variable -> model.insert(action.name) + Action.Backspace -> model.backspace() + Action.Clear -> model.clear() + Action.Equals -> model.commit() + Action.Keyboard -> model.startTyping() + // The converter's key; the calculator has `-` for that. + Action.Sign -> {} + } +} + +/** + * Decides whether focusing the expression raises the system keyboard. + * + * Off, the field still takes focus, shows a cursor and accepts a tap to move it; it just + * never opens an input connection, so the pad is the only way in. Compose's documented + * way to suppress the keyboard for one field. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun KeyboardGate(allow: Boolean, content: @Composable () -> Unit) { + InterceptPlatformTextInput( + interceptor = { request, next -> + if (allow) next.startInputMethod(request) else awaitCancellation() + }, + content = content, + ) +} + +// -- The display -- + +/** Operators drawn as their glyphs. One character for one, so the cursor maps straight across. */ +private fun glyphs(text: String): String = buildString(text.length) { + for (c in text) { + append( + when (c) { + '*' -> '\u00D7' + '/' -> '\u00F7' + '-' -> '\u2212' + else -> c + }, + ) + } +} + +private object OperatorGlyphs : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText = + TransformedText(AnnotatedString(glyphs(text.text)), OffsetMapping.Identity) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun Display(model: TallyViewModel, focusRequester: FocusRequester, modifier: Modifier = Modifier) { + val colors = MaterialTheme.colorScheme + val clipboard = LocalClipboardManager.current + + Column( + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = Alignment.End, + modifier = modifier + .background(colors.surfaceContainerLow) + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + BasicTextField( + value = model.field, + onValueChange = model::onFieldChange, + textStyle = TextStyle( + fontSize = 34.sp, + textAlign = TextAlign.End, + color = colors.onSurface, + ), + cursorBrush = SolidColor(colors.primary), + visualTransformation = OperatorGlyphs, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Ascii, + imeAction = ImeAction.Done, + autoCorrectEnabled = false, + ), + keyboardActions = KeyboardActions(onDone = { + model.stopTyping() + model.commit() + }), + maxLines = 3, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + decorationBox = { inner -> + Box(contentAlignment = Alignment.CenterEnd, modifier = Modifier.fillMaxWidth()) { + // After `=`, the expression that produced the answer, until the next key. + val committed = model.committed + if (model.field.text.isEmpty() && committed != null) { + Text( + text = glyphs(committed.expression), + fontSize = 22.sp, + color = colors.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + inner() + } + }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + val committed = model.committed + val error = model.error + val preview = model.preview + when { + error != null -> Text( + text = error, + color = colors.error, + fontSize = 22.sp, + textAlign = TextAlign.End, + ) + committed != null -> Answer( + result = committed.result, + color = colors.primary, + prefix = "", + large = true, + onCopy = { clipboard.setText(AnnotatedString(it)) }, + ) + preview != null -> Answer( + result = preview, + color = colors.onSurfaceVariant, + prefix = "= ", + large = false, + onCopy = { clipboard.setText(AnnotatedString(it)) }, + ) + } + } +} + +/** A size that fits a phone's width: 2^100 has 41 characters with its separators. */ +private fun answerSize(length: Int, large: Boolean): TextUnit = when { + length <= 12 -> if (large) 48.sp else 30.sp + length <= 20 -> if (large) 36.sp else 26.sp + length <= 32 -> if (large) 26.sp else 22.sp + length <= 44 -> 18.sp + else -> 16.sp +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun Answer(result: TallyResult, color: Color, prefix: String, large: Boolean, onCopy: (String) -> Unit) { + val display = result.display ?: return + val conversion = result.conversion + val text = if (conversion == null) display else "$display ${conversion.to}" + + Column( + horizontalAlignment = Alignment.End, + modifier = Modifier.combinedClickable( + onLongClickLabel = "copy the answer", + onLongClick = { onCopy(display) }, + onClick = {}, + ), + ) { + Text( + text = prefix + glyphs(text), + color = color, + fontSize = answerSize(text.length, large), + textAlign = TextAlign.End, + maxLines = 3, + ) + // The other bases, when the expression was written in one (FR-1.9). + result.bases?.let { bases -> + for (row in listOf("hex ${bases.hex}", "oct ${bases.oct}", "bin ${bases.bin}")) { + Text( + text = row, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.End, + ) + } + } + } +} + +// -- History -- + +/** + * Every committed calculation, newest first: tap to bring the expression back, long-press + * to copy the answer. A screen of its own, as in NCalc, so the calculator itself keeps + * all its height for the display and the pad. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun HistoryScreen(model: TallyViewModel) { + val colors = MaterialTheme.colorScheme + val clipboard = LocalClipboardManager.current + + Column(modifier = Modifier.fillMaxSize().background(colors.surface)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().height(44.dp).padding(horizontal = 4.dp), + ) { + TextButton(onClick = model::closeHistory) { Text("Back") } + Spacer(modifier = Modifier.weight(1f)) + if (model.history.isNotEmpty()) { + TextButton(onClick = model::clearHistory) { Text("Clear") } + } + } + HorizontalDivider() + + if (model.history.isEmpty()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxSize(), + ) { + Text("No calculations yet", color = colors.onSurfaceVariant) + // The version crossed JNI to get here, so seeing it means the library loaded. + Text( + text = "Tally ${model.engineVersion}", + style = MaterialTheme.typography.bodySmall, + color = colors.onSurfaceVariant, + ) + } + return@Column + } + + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(model.history.asReversed()) { entry -> + Column( + horizontalAlignment = Alignment.End, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClickLabel = "use this expression", + onLongClickLabel = "copy the answer", + onClick = { model.recall(entry) }, + onLongClick = { entry.result.display?.let { clipboard.setText(AnnotatedString(it)) } }, + ) + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + Text( + text = glyphs(entry.expression), + color = colors.onSurfaceVariant, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = "= " + glyphs(entry.result.display ?: ""), + color = colors.onSurface, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.End, + ) + } + HorizontalDivider(color = colors.outlineVariant) + } + } + } +} diff --git a/android/app/src/main/java/dev/lerch/tally/ConvertScreen.kt b/android/app/src/main/java/dev/lerch/tally/ConvertScreen.kt new file mode 100644 index 0000000..638f6f7 --- /dev/null +++ b/android/app/src/main/java/dev/lerch/tally/ConvertScreen.kt @@ -0,0 +1,476 @@ +package dev.lerch.tally + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel + +/** + * The converter (design 9.6). Built against NCalc's, where converting a temperature is + * Unit Converter, then Temperature, then the input unit, then the number. + * + * Here the screen reopens where it was left, so the common case is no navigation at + * all. The top row is every category, most recently used first, after a search tile + * that never moves; each category remembers its own value and unit, so a tap on + * Temperature lands on `98.6 F` and a tap on Length on `180 mi`. Within a category every + * unit is listed in a fixed order, the one on display highlighted in place. + */ +@Composable +internal fun ConvertScreen( + onMenu: () -> Unit, + onCalculator: () -> Unit, + model: ConvertViewModel = viewModel(), +) { + BackHandler(enabled = model.picker != null) { model.closePicker() } + + val state = model.state + val units = model.units + + when (model.picker) { + Picker.Search -> if (units != null && state != null) { + SearchPicker(model, units, state) + return + } + null -> {} + } + + Column(modifier = Modifier.fillMaxSize()) { + TopRow(title = "Convert", onMenu = onMenu) { + TextButton(onClick = onCalculator) { Text("Calculator") } + } + if (units == null || state == null) return@Column + CategoryRow(model, units, state) + ValueLine(model, units, state) + HorizontalDivider() + Results(model, units, state, modifier = Modifier.weight(1f)) + KeyBlock( + rows = converterRows, + background = MaterialTheme.colorScheme.surfaceContainerHigh, + onAction = { action -> handle(model, action) }, + onStore = {}, + onClearAll = model::clear, + modifier = Modifier.fillMaxWidth().height(280.dp), + ) + } +} + +private fun handle(model: ConvertViewModel, action: Action) { + when (action) { + is Action.Insert -> if (action.text == ".") model.point() else model.digit(action.text) + Action.Backspace -> model.backspace() + Action.Clear -> model.clear() + Action.Sign -> model.toggleSign() + is Action.Variable, Action.Equals, Action.Keyboard -> {} + } +} + +/** + * Search first, where it never moves, then every category, most recent first. A tap + * moves the category to the front, so the row scrolls back to show it there. + */ +@Composable +private fun CategoryRow(model: ConvertViewModel, units: UnitIndex, state: Converter) { + val colors = MaterialTheme.colorScheme + val scroll = rememberLazyListState() + LaunchedEffect(state.current) { scroll.animateScrollToItem(0) } + + LazyRow( + state = scroll, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().height(60.dp).padding(horizontal = 12.dp, vertical = 4.dp), + ) { + item(key = "search") { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxHeight() + .clip(RoundedCornerShape(10.dp)) + .background(colors.tertiaryContainer) + .clickable(role = Role.Button, onClickLabel = "search units", onClick = model::openSearch) + .padding(horizontal = 14.dp), + ) { + Icon(Icons.Filled.Search, contentDescription = null, tint = colors.onTertiaryContainer) + Text("Search", style = MaterialTheme.typography.labelSmall, color = colors.onTertiaryContainer) + } + } + items(state.order, key = { it }) { name -> + val category = units.categories.getValue(name) + val selected = name == state.current + val summary = model.summary(name) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxHeight() + .widthIn(max = 160.dp) + .clip(RoundedCornerShape(10.dp)) + .background(if (selected) colors.primary else colors.surfaceContainerHigh) + .clickable(role = Role.Button, onClick = { model.selectCategory(name) }) + .padding(horizontal = 12.dp), + ) { + Text( + text = category.label, + color = if (selected) colors.onPrimary else colors.onSurface, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + ) + // Where a tap lands, as a value: `98.6 F` reads as where you left off, + // where a bare `F` would read as the only unit on offer. + if (summary != null) { + Text( + text = summary.replace('-', '\u2212'), + color = if (selected) colors.onPrimary else colors.onSurfaceVariant, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +/** The number, and the unit it is shown in. Tap the unit for the rest of the category. */ +@Composable +private fun ValueLine(model: ConvertViewModel, units: UnitIndex, state: Converter) { + val colors = MaterialTheme.colorScheme + val raw = model.shownValue() + val shown = raw.ifEmpty { "0" } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Box(contentAlignment = Alignment.CenterEnd, modifier = Modifier.weight(1f)) { + // A value the user did not just type is shown as if selected: the next digit + // replaces it, and this is how that is said without words. + Text( + text = shown.replace('-', '\u2212'), + fontSize = when { + shown.length <= 9 -> 40.sp + shown.length <= 13 -> 30.sp + else -> 22.sp + }, + color = if (raw.isEmpty()) colors.onSurfaceVariant else colors.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(if (state.fresh) colors.primaryContainer else colors.surface) + .padding(horizontal = 8.dp), + ) + } + Spacer(modifier = Modifier.width(12.dp)) + UnitDropdown(model, units, state) + } +} + +/** + * The unit button, and the category's units in a menu hanging from it: a short list, so + * it opens in place with the value still on screen, rather than as a screen of its own. + * Picking one is a result-row tap: the quantity is shown in that unit, and typing then + * goes into it. + */ +@Composable +private fun UnitDropdown(model: ConvertViewModel, units: UnitIndex, state: Converter) { + val colors = MaterialTheme.colorScheme + val shownUnit = state.here.shownUnit + val unit = units.unit(shownUnit) + var open by remember { mutableStateOf(false) } + + Box { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(colors.secondaryContainer) + .clickable(role = Role.Button, onClickLabel = "choose a unit", onClick = { open = true }) + .padding(horizontal = 14.dp, vertical = 6.dp), + ) { + Text( + text = "$shownUnit \u25BE", + color = colors.onSecondaryContainer, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + if (unit != null && unit.longName.isNotEmpty()) { + Text( + unit.longName, + color = colors.onSecondaryContainer, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 120.dp), + ) + } + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + for (option in units.categories.getValue(state.current).units) { + val selected = option.name == shownUnit + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + option.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + modifier = Modifier.width(64.dp), + ) + Text(option.longName, color = colors.onSurfaceVariant) + } + }, + trailingIcon = if (selected) { + { Icon(Icons.Filled.Check, contentDescription = "current") } + } else { + null + }, + onClick = { + open = false + model.showUnit(option.name) + }, + ) + } + } + } +} + +/** + * Every unit in the category, in the engine's table order, which never changes: where + * a unit is on screen is something to remember. The one on display is highlighted where + * it sits rather than removed, so tapping a row moves nothing but the highlight. + * + * The list moves only when the user scrolls it. Each category keeps its own position, + * restored without animation when the category is shown and saved when it is left, the + * screen goes away, or the app goes to the background. Choosing a unit never scrolls: + * the value line already says what was chosen, and someone reading down the column + * comparing values would lose their place. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun Results(model: ConvertViewModel, units: UnitIndex, state: Converter, modifier: Modifier = Modifier) { + val colors = MaterialTheme.colorScheme + val clipboard = LocalClipboardManager.current + val categoryName = state.current + val category = units.categories.getValue(categoryName) + + // One list state per category, built from what it remembered. Keyed on the category, + // so a switch starts from that category's own position rather than the last one's. + val scroll = remember(categoryName) { + val saved = state.memory[categoryName] + val index = category.units.indexOfFirst { it.name == saved?.scrollUnit } + if (index < 0) LazyListState() else LazyListState(index, saved?.scrollOffset ?: 0) + } + fun report() { + val top = category.units.getOrNull(scroll.firstVisibleItemIndex)?.name + model.saveScroll(categoryName, top, scroll.firstVisibleItemScrollOffset) + } + // Leaving the category, or the screen: this state is about to be discarded. + DisposableEffect(categoryName) { onDispose { report() } } + // The app going to the background, which may be the last chance before it is killed. + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle, categoryName) { + val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_STOP) report() } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } + + LazyColumn(state = scroll, modifier = modifier.fillMaxWidth()) { + items(category.units, key = { it.name }) { unit -> + val row = model.display(unit.name) + val selected = unit.name == state.here.shownUnit + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .background(if (selected) colors.secondaryContainer else colors.surface) + .combinedClickable( + onClickLabel = "show in ${unit.name}", + onLongClickLabel = "copy", + onClick = { model.showUnit(unit.name) }, + onLongClick = { row?.display?.let { clipboard.setText(AnnotatedString(it)) } }, + ) + .padding(horizontal = 16.dp, vertical = 10.dp), + ) { + Column(modifier = Modifier.width(110.dp)) { + Text(unit.name, style = MaterialTheme.typography.titleMedium, color = colors.onSurface) + if (unit.longName.isNotEmpty()) { + Text( + unit.longName, + style = MaterialTheme.typography.bodySmall, + color = colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + // Approximate rows say so: degrees to radians goes through pi. + text = row?.display?.let { (if (row.exact) "" else "\u2248 ") + it.replace('-', '\u2212') } + ?: (row?.error ?: ""), + style = MaterialTheme.typography.titleLarge, + color = if (row != null && row.display == null) colors.error else colors.onSurface, + textAlign = TextAlign.End, + maxLines = 2, + modifier = Modifier.weight(1f), + ) + } + HorizontalDivider(color = colors.outlineVariant) + } + } +} + +@Composable +private fun UnitChip(unit: UnitInfo, selected: Boolean, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(if (selected) colors.primary else colors.surfaceContainerHigh) + .clickable(role = Role.Button, onClick = onClick) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Text( + text = unit.name, + color = if (selected) colors.onPrimary else colors.onSurface, + style = MaterialTheme.typography.titleSmall, + ) + if (unit.longName.isNotEmpty()) { + Text( + text = unit.longName, + color = if (selected) colors.onPrimary else colors.onSurfaceVariant, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + ) + } + } +} + +/** + * Every unit there is, searchable by name, alias or category. The search tile was asked + * for, so the field is focused at once. Before anything is typed, every category is + * listed in the row's order: what you used before comes first, and for someone new the + * first-run order is the suggestion. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SearchPicker(model: ConvertViewModel, units: UnitIndex, state: Converter) { + var query by remember { mutableStateOf("") } + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { focus.requestFocus() } + + val matching = state.order + .map { units.categories.getValue(it) } + .map { category -> category to category.units.filter { it.matches(query) } } + .filter { it.second.isNotEmpty() } + + Column(modifier = Modifier.fillMaxSize()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().height(48.dp).padding(horizontal = 4.dp), + ) { + TextButton(onClick = model::closePicker) { Text("Cancel") } + Text("Find a unit", style = MaterialTheme.typography.titleMedium) + } + OutlinedTextField( + value = query, + onValueChange = { query = it }, + placeholder = { Text("mile, kelvin, knot, speed...") }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { + matching.flatMap { it.second }.singleOrNull()?.let { model.showUnit(it.name) } + }), + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).focusRequester(focus), + ) + LazyColumn(modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp)) { + for ((category, found) in matching) { + item(key = category.name) { Section(category.label) } + item(key = category.name + ".units") { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (unit in found) { + val selected = category.name == state.current && unit.name == state.here.shownUnit + UnitChip(unit, selected) { model.showUnit(unit.name) } + } + } + } + } + if (matching.isEmpty()) { + item { Section("Nothing matches \"$query\"") } + } + item { Spacer(modifier = Modifier.height(24.dp)) } + } + } +} + +@Composable +private fun Section(title: String) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp), + ) +} diff --git a/android/app/src/main/java/dev/lerch/tally/ConvertViewModel.kt b/android/app/src/main/java/dev/lerch/tally/ConvertViewModel.kt new file mode 100644 index 0000000..1447a8f --- /dev/null +++ b/android/app/src/main/java/dev/lerch/tally/ConvertViewModel.kt @@ -0,0 +1,221 @@ +package dev.lerch.tally + +import android.app.Application +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.concurrent.Executors + +/** The full-screen picker open over the converter, if any. Units within a category are a dropdown. */ +internal enum class Picker { Search } + +/** + * The converter (design 9.6): holds a [Converter], saves it on every change, and asks the + * engine for the numbers. The rules themselves are all in [Converter], where they are + * unit tested; this only connects them to the engine, the disk and the screen. + * + * It has its own session, on its own thread, for the reasons [TallyViewModel] gives: + * sessions share nothing, so two of them need no coordination. + */ +internal class ConvertViewModel(app: Application) : AndroidViewModel(app) { + private val prefs = AppPrefs(app) + private val engineThread = Executors.newSingleThreadExecutor { task -> Thread(task, "tally-convert") } + private val engine = engineThread.asCoroutineDispatcher() + private val session = TallySession() + + /** Null until the engine's catalogue has been read, which is the first thing it does. */ + var units by mutableStateOf(null) + private set + + var state by mutableStateOf(null) + private set + + /** The current value in every unit of its category, from the engine. */ + private var table by mutableStateOf(null) + + /** What [table] was computed from; a table for anything else is stale. */ + private var tableFor: Pair? = null + + var picker by mutableStateOf(null) + private set + + private var job: Job? = null + + init { + viewModelScope.launch { + val catalog = withContext(engine) { + // A list of a dozen units reads as a table, not a proof: ten decimals is + // plenty, where the calculator shows twenty. The digits are still exact + // where the conversion is - this only decides how many are printed. + session.configureDisplay(fractionDigits = 10, significantDigits = 12) + UnitCatalog.parse(session.unitCatalog()) + } + if (catalog.isEmpty()) return@launch + val index = UnitIndex(catalog) + val (value, source, shown) = prefs.legacyConverter + units = index + set( + Converter.restore(prefs.converter, index) + ?: Converter.fromLegacy(value, source, shown, index) + ?: Converter.initial(index), + ) + } + } + + // -- What the screen reads -- + + /** The rendering of the current value in [unit], when the engine has answered. */ + fun display(unit: String): ConversionTable.Row? { + val current = table ?: return null + val s = state ?: return null + if (tableFor != s.here.value to s.here.sourceUnit) return null + return current.rows.firstOrNull { it.unit == unit } + } + + /** The main value as shown: what was typed, or its rendering in the unit on display. */ + fun shownValue(): String { + val m = state?.here ?: return "" + if (m.shownUnit == m.sourceUnit) return m.value + return display(m.shownUnit)?.display ?: m.value + } + + /** + * The chip's second line for [category]: the value it would land on, or null for a + * category never visited. The one on screen reads live from the table. + */ + fun summary(category: String): String? { + val s = state ?: return null + if (category == s.current) { + val m = s.here + return "${shownValue().ifEmpty { "0" }} ${m.shownUnit}" + } + return s.memory[category]?.summary() + } + + // -- Actions -- + + fun selectCategory(name: String) = update { it.selectCategory(name, units!!) } + + fun showUnit(unit: String) { + picker = null + update { it.showUnit(unit, units!!) } + } + + fun digit(d: String) = update { it.digit(d, shownRendering()) } + fun point() = update { it.point(shownRendering()) } + fun backspace() = update { it.backspace(shownRendering()) } + fun clear() = update { it.clear(shownRendering()) } + fun toggleSign() = update { it.toggleSign(shownRendering()) } + + /** + * Where [category]'s list is scrolled to, as the screen reports it on the way out: a + * category switch, leaving the screen, or the app going to the background. Saved, but + * nothing is recomputed - it is not a change to the quantity. + */ + fun saveScroll(category: String, unit: String?, offset: Int) { + val s = state ?: return + val next = s.withScroll(category, unit, offset, units ?: return) + if (next == s) return + state = next + prefs.converter = next.serialize() + } + + fun openSearch() { + picker = Picker.Search + } + + fun closePicker() { + picker = null + } + + /** What editing starts from when the unit on screen is not the one the value is in. */ + private fun shownRendering(): String? { + val m = state?.here ?: return null + if (m.shownUnit == m.sourceUnit) return null + return display(m.shownUnit)?.display + } + + private inline fun update(change: (Converter) -> Converter) { + val current = state ?: return + set(change(current)) + } + + private fun set(next: Converter) { + state = next + prefs.converter = next.serialize() + val m = next.here + if (tableFor == m.value to m.sourceUnit) syncDisplay() else refresh() + } + + /** Keep the category's remembered rendering current, so its chip is right later. */ + private fun syncDisplay() { + val s = state ?: return + val next = s.withShownDisplay(display(s.here.shownUnit)?.display) + if (next != s) { + state = next + prefs.converter = next.serialize() + } + } + + /** Newest wins: an answer for a value that has since changed is dropped. */ + private fun refresh() { + job?.cancel() + val m = state?.here ?: return + val key = m.value to m.sourceUnit + if (m.value.isBlank() || m.value == "-") { + table = null + tableFor = key + syncDisplay() + return + } + job = viewModelScope.launch { + val result = withContext(engine) { session.convert(m.value, m.sourceUnit) } + val now = state?.here ?: return@launch + if (now.value to now.sourceUnit != key) return@launch + table = result.takeIf { it.ok } + tableFor = key + syncDisplay() + } + } + + override fun onCleared() { + engineThread.execute { session.close() } + engineThread.shutdown() + super.onCleared() + } +} + +/** The screen and the theme, both remembered across launches. */ +internal class AppViewModel(app: Application) : AndroidViewModel(app) { + private val prefs = AppPrefs(app) + + var screen by mutableStateOf(prefs.screen) + private set + + /** Where Settings goes back to. */ + private var lastWorkScreen = prefs.screen + + var theme by mutableStateOf(prefs.theme) + private set + + fun open(target: Screen) { + screen = target + if (target != Screen.Settings) { + lastWorkScreen = target + prefs.screen = target + } + } + + fun leaveSettings() = open(lastWorkScreen) + + fun chooseTheme(choice: ThemeChoice) { + theme = choice + prefs.theme = choice + } +} diff --git a/android/app/src/main/java/dev/lerch/tally/Converter.kt b/android/app/src/main/java/dev/lerch/tally/Converter.kt new file mode 100644 index 0000000..552be3b --- /dev/null +++ b/android/app/src/main/java/dev/lerch/tally/Converter.kt @@ -0,0 +1,283 @@ +package dev.lerch.tally + +/** + * The converter's state, and every rule about how it changes (design 9.6). + * + * Plain Kotlin with no Android in it, so the rules run as JVM unit tests rather than + * only on a device. [ConvertViewModel] owns one of these, persists it, and asks the + * engine for the numbers; it decides nothing about the state itself. + * + * Immutable: every operation returns the next state. + */ +internal data class Converter( + /** Every category, most recently used first. Always the complete set. */ + val order: List, + /** The category on screen. Always has an entry in [memory]. */ + val current: String, + /** What each category was left at. Only categories that have been visited. */ + val memory: Map, + /** + * True while the value on screen is not one the user just typed - restored, or + * reached by a tap. The next digit replaces it rather than appending. + */ + val fresh: Boolean = true, +) { + /** What the category on screen remembers. */ + val here: CategoryMemory get() = memory.getValue(current) + + // -- Moving around -- + + /** + * Go to a category: it moves to the front of the row, and comes back exactly as it + * was left - `98.6 F`, `180 mi`. A category never visited starts at 1 of its base + * unit. + */ + fun selectCategory(name: String, units: UnitIndex): Converter { + if (name !in units.categories) return this + val remembered = memory[name] ?: units.startingMemory(name) + return copy( + order = listOf(name) + (order - name), + current = name, + memory = memory + (name to remembered), + fresh = true, + ) + } + + /** + * Show the quantity in [unit]: a result row, the unit picker, or a search result. + * + * Within the category nothing is recomputed - the rows are already the typed value + * in every unit - so viewing from miles and back to kilometres is exact. A unit in + * another category goes there first; if that category was never visited, the unit + * becomes what it starts in. + */ + fun showUnit(unit: String, units: UnitIndex): Converter { + val category = units.categoryOf(unit) ?: return this + if (category != current) { + val visited = category in memory + val moved = selectCategory(category, units) + if (!visited) { + return moved.copy(memory = moved.memory + (category to units.startingMemory(category, unit))) + } + return moved.showUnit(unit, units) + } + if (unit == here.shownUnit) return copy(fresh = true) + return copy(memory = memory + (current to here.copy(shownUnit = unit, shownDisplay = null)), fresh = true) + } + + /** Record how the engine rendered the value in the shown unit, for the category's chip. */ + fun withShownDisplay(display: String?): Converter { + if (display == here.shownDisplay) return this + return copy(memory = memory + (current to here.copy(shownDisplay = display))) + } + + // -- The pad -- + // + // Each takes [shown], the engine's rendering of the value in the shown unit. Editing + // starts from what is on screen: once the user types, the unit they are looking at + // becomes the one the value is in. + + fun digit(d: String, shown: String?): Converter { + if (fresh) return edited(d, shown) + val start = editingValue(shown) + return edited(if (start == "0") d else start + d, shown) + } + + fun point(shown: String?): Converter { + val start = if (fresh) "" else editingValue(shown) + val lastNumber = start.takeLastWhile { it.isDigit() || it == '.' } + val next = when { + start.isEmpty() -> "0." + '.' in lastNumber -> return this + lastNumber.isEmpty() -> start + "0." + else -> "$start." + } + return edited(next, shown) + } + + /** Edits what is on screen, even straight after a tap: it is the number you can see. */ + fun backspace(shown: String?): Converter = edited(editingValue(shown).dropLast(1), shown) + + fun clear(shown: String?): Converter = edited("", shown) + + /** `-40` is what you want for temperatures, and nobody wants to type the minus first. */ + fun toggleSign(shown: String?): Converter { + val start = editingValue(shown) + return edited(if (start.startsWith("-")) start.drop(1) else "-$start", shown) + } + + /** + * The value to edit, as typed text in the shown unit. Without a rendering yet - the + * engine has not answered - editing stays in the unit the value was typed in, rather + * than reading that number as a different unit. + */ + private fun editingValue(shown: String?): String { + val m = here + if (m.shownUnit == m.sourceUnit) return m.value + return shown?.let(::typedForm) ?: m.value + } + + private fun edited(value: String, shown: String?): Converter { + val m = here + // The unit the value is now in: the one on screen, if it could be read from there. + val unit = if (m.shownUnit == m.sourceUnit || shown != null) m.shownUnit else m.sourceUnit + // A copy, so where the list is scrolled to survives typing. + val next = m.copy(value = value, sourceUnit = unit, shownUnit = unit, shownDisplay = null) + return copy(memory = memory + (current to next), fresh = false) + } + + /** + * Record where [category]'s list is scrolled to: the unit in the top row, and how many + * pixels of it are scrolled off. Anchored by name rather than row number, so a unit the + * engine adds above it later does not move the list onto a different unit. Nothing + * else about the category changes, and nothing else ever moves the list. + */ + fun withScroll(category: String, unit: String?, offset: Int, units: UnitIndex): Converter { + val m = memory[category] ?: return this + val anchor = unit?.takeIf { units.categoryOf(it) == category } + val next = m.copy(scrollUnit = anchor, scrollOffset = if (anchor == null) 0 else offset.coerceAtLeast(0)) + if (next == m) return this + return copy(memory = memory + (category to next)) + } + + // -- Persistence -- + + /** + * One line per fact, tab-separated. Every field is pad output, a unit name, an engine + * rendering or a number, none of which can hold a tab or a newline. The two scroll + * fields came later and are optional on the way back in, so older saves still load. + */ + fun serialize(): String = buildString { + append(FORMAT).append('\n') + append("order\t").append(order.joinToString(",")).append('\n') + append("current\t").append(current).append('\n') + for ((category, m) in memory) { + append("mem\t").append(category) + append('\t').append(m.value) + append('\t').append(m.sourceUnit) + append('\t').append(m.shownUnit) + append('\t').append(m.shownDisplay ?: "") + append('\t').append(m.scrollUnit ?: "") + append('\t').append(m.scrollOffset) + append('\n') + } + } + + companion object { + private const val FORMAT = "converter v1" + + /** The first-run order: the categories people reach for, then the rest. */ + private val preferred = listOf("temperature", "length", "mass", "volume", "speed") + + fun initial(units: UnitIndex): Converter { + val order = defaultOrder(units) + val first = order.first() + return Converter(order, first, mapOf(first to units.startingMemory(first))) + } + + /** + * Restore saved state against the engine's current tables. Anything the engine + * no longer has is dropped, and categories it has gained are added to the end, + * so the row is always the complete set. Null if [text] is not a saved state. + */ + fun restore(text: String?, units: UnitIndex): Converter? { + if (text == null) return null + val lines = text.split('\n') + if (lines.firstOrNull() != FORMAT) return null + + var savedOrder: List = emptyList() + var savedCurrent: String? = null + val memory = LinkedHashMap() + for (line in lines.drop(1)) { + val fields = line.split('\t') + when (fields[0]) { + "order" -> savedOrder = fields.getOrNull(1)?.split(',').orEmpty() + "current" -> savedCurrent = fields.getOrNull(1) + "mem" -> { + if (fields.size < 6) continue + val category = fields[1] + val source = fields[3] + val shown = fields[4] + if (units.categoryOf(source) != category || units.categoryOf(shown) != category) continue + // An anchor the engine no longer has in this category means the + // top of the list: there is no honest "same place" without it. + val scrollUnit = fields.getOrNull(6)?.takeIf { units.categoryOf(it) == category } + memory[category] = CategoryMemory( + value = fields[2], + sourceUnit = source, + shownUnit = shown, + shownDisplay = fields[5].ifEmpty { null }, + scrollUnit = scrollUnit, + scrollOffset = if (scrollUnit == null) 0 else fields.getOrNull(7)?.toIntOrNull()?.coerceAtLeast(0) ?: 0, + ) + } + } + } + + val known = savedOrder.filter { it in units.categories }.distinct() + val order = known + defaultOrder(units).filter { it !in known } + val current = savedCurrent?.takeIf { it in units.categories } ?: order.first() + if (current !in memory) memory[current] = units.startingMemory(current) + return Converter(order, current, memory) + } + + /** The single value, unit and shown unit an older build saved, as a state. */ + fun fromLegacy(value: String?, source: String?, shown: String?, units: UnitIndex): Converter? { + if (value == null || source == null) return null + val category = units.categoryOf(source) ?: return null + val shownUnit = shown?.takeIf { units.categoryOf(it) == category } ?: source + val base = initial(units) + val m = CategoryMemory(value = value, sourceUnit = source, shownUnit = shownUnit) + return base.copy( + order = listOf(category) + (base.order - category), + current = category, + memory = base.memory + (category to m), + ) + } + + private fun defaultOrder(units: UnitIndex): List { + val all = units.categoryNames + return preferred.filter { it in units.categories } + all.filter { it !in preferred } + } + + /** An engine rendering as something the engine reads back: no grouping commas. */ + fun typedForm(display: String): String = display.replace(",", "") + } +} + +/** What one category was left at. */ +internal data class CategoryMemory( + /** As typed: engine syntax, such as `98.6` or `-40`. */ + val value: String, + /** The unit [value] is in. Every number on screen is computed from this pair. */ + val sourceUnit: String, + /** The unit shown as the main value; a result row tap changes only this. */ + val shownUnit: String, + /** The value as last rendered in [shownUnit], for the category chip. */ + val shownDisplay: String? = null, + /** The unit in the list's top row when the category was left; null for the top. */ + val scrollUnit: String? = null, + /** Pixels of that row scrolled off the top. */ + val scrollOffset: Int = 0, +) { + /** The chip's second line: where a tap on the category lands. */ + fun summary(): String = if (shownDisplay != null) "$shownDisplay $shownUnit" else "$value $sourceUnit" +} + +/** The engine's catalogue, indexed for the lookups the converter makes. */ +internal class UnitIndex(catalog: List) { + /** In the engine's table order, which is the order units are listed in. */ + val categories: Map = catalog.associateBy { it.name } + val categoryNames: List = catalog.map { it.name } + private val byName: Map = catalog.flatMap { it.units }.associateBy { it.name } + + fun unit(name: String): UnitInfo? = byName[name] + + fun categoryOf(unit: String): String? = byName[unit]?.category + + fun startingMemory(category: String, unit: String? = null): CategoryMemory { + val info = categories.getValue(category) + val start = unit ?: info.baseUnit.takeIf { b -> info.units.any { it.name == b } } ?: info.units.first().name + return CategoryMemory(value = "1", sourceUnit = start, shownUnit = start) + } +} diff --git a/android/app/src/main/java/dev/lerch/tally/Keypad.kt b/android/app/src/main/java/dev/lerch/tally/Keypad.kt new file mode 100644 index 0000000..d7390dc --- /dev/null +++ b/android/app/src/main/java/dev/lerch/tally/Keypad.kt @@ -0,0 +1,322 @@ +package dev.lerch.tally + +import android.view.HapticFeedbackConstants +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * The standard screen's pad, laid out after NCalc's: a scientific block on an accent + * background above a numeric block, with the rarer functions on a panel that slides up + * over the numbers. + * + * Every key that writes inserts plain engine syntax at the cursor - `*`, `sqrt(`, `pi` - + * so the pad and the system keyboard produce the same text and either can finish what + * the other started. Labels are what a person reads; [Key.text] is what the engine reads. + * Non-ASCII labels are written as escapes so the source stays ASCII. + */ +internal sealed interface Action { + /** Insert text at the cursor. [continues] means it extends a committed answer. */ + data class Insert(val text: String, val continues: Boolean = false) : Action + + /** A variable: tap inserts the name, long-press stores into it. */ + data class Variable(val name: String) : Action + + data object Backspace : Action + data object Clear : Action + data object Equals : Action + data object Keyboard : Action + + /** The converter's `+/-`: flip the sign of the whole value. */ + data object Sign : Action +} + +internal enum class Tone { Digit, Operator, Function, Clear, Equals } + +internal data class Key( + val label: String, + val action: Action, + val tone: Tone, + /** What a screen reader says, when the label is a symbol. */ + val spoken: String? = null, + /** Share of its row; a wide `0`, say. */ + val weight: Float = 1f, +) + +private const val TIMES = "\u00D7" +private const val DIVIDE = "\u00F7" +private const val MINUS = "\u2212" +private const val PI = "\u03C0" +private const val ROOT = "\u221A" +private const val SQUARED = "x\u00B2" +private const val BACKSPACE = "\u232B" + +private fun fn(label: String, name: String = label, spoken: String? = null) = + Key(label, Action.Insert("$name("), Tone.Function, spoken) + +private fun op(label: String, text: String, spoken: String? = null, tone: Tone = Tone.Operator) = + Key(label, Action.Insert(text, continues = true), tone, spoken) + +private fun digit(label: String) = Key(label, Action.Insert(label), Tone.Digit) + +private fun variable(name: String) = Key(name, Action.Variable(name), Tone.Function) + +/** Three rows, the ones reached for every day. */ +private val scientificRows: List> = listOf( + listOf(fn("sin"), fn("cos"), fn("tan"), fn("ln"), fn("log"), fn(ROOT, "sqrt", "square root")), + listOf( + Key("(", Action.Insert("("), Tone.Function, "open parenthesis"), + Key(")", Action.Insert(")"), Tone.Function, "close parenthesis"), + op("^", "^", "power", Tone.Function), + op(SQUARED, "^2", "squared", Tone.Function), + fn("n!", "factorial", "factorial"), + Key("e", Action.Insert("e"), Tone.Function, "e"), + ), + listOf( + variable("x"), + variable("A"), + variable("B"), + // `1,234` is a grouped number to the engine, so a separator needs its space. + Key(",", Action.Insert(", "), Tone.Function, "comma"), + Key("mod", Action.Insert(" % ", continues = true), Tone.Function, "modulo"), + Key("abc", Action.Keyboard, Tone.Function, "type with the keyboard"), + ), +) + +/** The converter's pad: digits and the few keys a quantity needs, nothing else. */ +internal val converterRows: List> = listOf( + listOf(digit("7"), digit("8"), digit("9"), Key(BACKSPACE, Action.Backspace, Tone.Clear, "backspace")), + listOf(digit("4"), digit("5"), digit("6"), Key("CLR", Action.Clear, Tone.Clear, "clear")), + listOf(digit("1"), digit("2"), digit("3"), Key("+/\u2212", Action.Sign, Tone.Operator, "change sign")), + listOf( + Key("0", Action.Insert("0"), Tone.Digit, weight = 2f), + Key(".", Action.Insert("."), Tone.Digit, "point", weight = 2f), + ), +) + +/** NCalc's numeric block, key for key. */ +private val numericRows: List> = listOf( + listOf(digit("7"), digit("8"), digit("9"), Key("CLR", Action.Clear, Tone.Clear, "clear"), Key(BACKSPACE, Action.Backspace, Tone.Clear, "backspace")), + listOf(digit("4"), digit("5"), digit("6"), op(TIMES, "*", "times"), op(DIVIDE, "/", "divided by")), + listOf(digit("1"), digit("2"), digit("3"), op("+", "+", "plus"), op(MINUS, "-", "minus")), + listOf( + digit("0"), + Key(".", Action.Insert("."), Tone.Digit, "point"), + Key(PI, Action.Insert("pi"), Tone.Digit, "pi"), + Key("Ans", Action.Insert("Ans"), Tone.Digit, "answer"), + Key("=", Action.Equals, Tone.Equals, "equals"), + ), +) + +/** The panel behind the handle: every other built-in the engine has. */ +private val advancedRows: List> = listOf( + listOf(fn("asin"), fn("acos"), fn("atan"), fn("atan2")), + listOf(fn("exp"), fn("log2"), fn("log10"), fn("cbrt", spoken = "cube root")), + listOf(fn("abs"), fn("floor"), fn("ceil"), fn("round")), + listOf(fn("min"), fn("max"), Key("tau", Action.Insert("tau"), Tone.Function), variable("C")), +) + +@Composable +internal fun Keypad( + onAction: (Action) -> Unit, + onStore: (String) -> Unit, + onClearAll: () -> Unit, + modifier: Modifier = Modifier, +) { + var advanced by remember { mutableStateOf(false) } + + Column(modifier = modifier) { + KeyBlock( + rows = scientificRows, + background = MaterialTheme.colorScheme.secondaryContainer, + onAction = onAction, + onStore = onStore, + onClearAll = onClearAll, + // NCalc's proportions: the scientific rows a little shorter than the digits. + modifier = Modifier.weight(2.5f), + ) + Box(modifier = Modifier.weight(4f)) { + KeyBlock( + rows = numericRows, + background = MaterialTheme.colorScheme.surfaceContainerHigh, + onAction = onAction, + onStore = onStore, + onClearAll = onClearAll, + modifier = Modifier.fillMaxSize(), + ) + // Qualified, because inside the Column the scoped overload would win and it + // cannot be called from the Box. + androidx.compose.animation.AnimatedVisibility( + visible = advanced, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + ) { + KeyBlock( + rows = advancedRows, + background = MaterialTheme.colorScheme.tertiaryContainer, + onAction = { action -> + onAction(action) + // A function picked from the panel is the end of the trip to it. + advanced = false + }, + onStore = onStore, + onClearAll = onClearAll, + modifier = Modifier.fillMaxSize(), + ) + } + } + PanelHandle(open = advanced, onToggle = { advanced = !advanced }) + } +} + +@Composable +private fun PanelHandle(open: Boolean, onToggle: () -> Unit) { + val view = LocalView.current + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .height(32.dp) + .background(MaterialTheme.colorScheme.tertiary) + .clickable( + role = Role.Button, + onClickLabel = if (open) "show the numbers" else "show more functions", + onClick = { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + onToggle() + }, + ), + ) { + Text( + text = if (open) "numbers" else "functions", + color = MaterialTheme.colorScheme.onTertiary, + style = MaterialTheme.typography.labelLarge, + ) + } +} + +@Composable +internal fun KeyBlock( + rows: List>, + background: Color, + onAction: (Action) -> Unit, + onStore: (String) -> Unit, + onClearAll: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .background(background) + .padding(horizontal = 4.dp, vertical = 2.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + for (row in rows) { + Row(modifier = Modifier.fillMaxWidth().weight(1f)) { + for (key in row) { + KeyButton( + key = key, + onAction = onAction, + onStore = onStore, + onClearAll = onClearAll, + modifier = Modifier.weight(key.weight).fillMaxHeight(), + ) + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun KeyButton( + key: Key, + onAction: (Action) -> Unit, + onStore: (String) -> Unit, + onClearAll: () -> Unit, + modifier: Modifier = Modifier, +) { + val view = LocalView.current + val colors = MaterialTheme.colorScheme + val (container, content) = when (key.tone) { + Tone.Digit -> colors.surfaceContainerHighest to colors.onSurface + Tone.Operator -> colors.secondary to colors.onSecondary + Tone.Function -> Color.Transparent to colors.onSecondaryContainer + Tone.Clear -> colors.errorContainer to colors.onErrorContainer + Tone.Equals -> colors.primary to colors.onPrimary + } + + // Long-press does something only where there is something worth hiding behind it: + // storing into a variable, and clearing everything from backspace. + val longPress: (() -> Unit)? = when (val action = key.action) { + is Action.Variable -> { { onStore(action.name) } } + Action.Backspace -> onClearAll + else -> null + } + val longPressLabel = when (key.action) { + is Action.Variable -> "store in ${key.label}" + Action.Backspace -> "clear" + else -> null + } + + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .padding(3.dp) + .clip(RoundedCornerShape(12.dp)) + .background(container) + .semantics { key.spoken?.let { contentDescription = it } } + .combinedClickable( + role = Role.Button, + onLongClickLabel = longPressLabel, + onLongClick = longPress?.let { action -> + { + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + action() + } + }, + onClick = { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + onAction(key.action) + }, + ), + ) { + Text( + text = key.label, + color = content, + fontSize = when (key.tone) { + Tone.Digit, Tone.Equals, Tone.Operator -> 26.sp + else -> 17.sp + }, + ) + } +} diff --git a/android/app/src/main/java/dev/lerch/tally/MainActivity.kt b/android/app/src/main/java/dev/lerch/tally/MainActivity.kt new file mode 100644 index 0000000..371a76b --- /dev/null +++ b/android/app/src/main/java/dev/lerch/tally/MainActivity.kt @@ -0,0 +1,233 @@ +package dev.lerch.tally + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +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.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.launch + +/** + * The shell: a hamburger drawer, as in NCalc, over whichever screen was open last. + * + * The screen and the theme are remembered across launches ([AppViewModel]); each screen + * remembers its own state. + */ +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // Edge to edge, so the keyboard's height arrives as an inset: that is how the + // calculator notices the keyboard has been dismissed and brings the pad back. + enableEdgeToEdge() + super.onCreate(savedInstanceState) + setContent { + val app: AppViewModel = viewModel() + TallyTheme(app.theme) { + AppShell(app) + } + } + } +} + +/** + * Light or dark as [choice] says - which by default is whatever the device says. + * + * Fixed palettes rather than the system's dynamic colours (Material You). The dynamic + * ones were tried first, and on the test device they gave the keypad's accent bands + * light containers in dark mode: what a calculator looks like should not depend on the + * wallpaper. Material's baseline schemes, so every role the screens use has a value + * designed against its pair. + */ +@Composable +private fun TallyTheme(choice: ThemeChoice, content: @Composable () -> Unit) { + val dark = when (choice) { + ThemeChoice.System -> isSystemInDarkTheme() + ThemeChoice.Light -> false + ThemeChoice.Dark -> true + } + val context = LocalContext.current + + // The status and navigation bar icons have to follow the app's choice, not the + // device's, or a dark app on a light device gets dark icons on a dark bar. + DisposableEffect(dark) { + val activity = context as? ComponentActivity + val transparent = android.graphics.Color.TRANSPARENT + val style = if (dark) { + SystemBarStyle.dark(transparent) + } else { + SystemBarStyle.light(transparent, transparent) + } + activity?.enableEdgeToEdge(statusBarStyle = style, navigationBarStyle = style) + onDispose {} + } + + MaterialTheme(colorScheme = if (dark) darkColorScheme() else lightColorScheme(), content = content) +} + +@Composable +private fun AppShell(app: AppViewModel) { + val drawer = rememberDrawerState(DrawerValue.Closed) + val scope = rememberCoroutineScope() + val version = remember { TallyEngine.versionString() ?: "unknown" } + + fun go(target: Screen) { + app.open(target) + scope.launch { drawer.close() } + } + val openMenu: () -> Unit = { scope.launch { drawer.open() } } + + BackHandler(enabled = drawer.isOpen) { scope.launch { drawer.close() } } + BackHandler(enabled = app.screen == Screen.Settings && !drawer.isOpen) { app.leaveSettings() } + + ModalNavigationDrawer( + drawerState = drawer, + // Opened by the hamburger only: an edge swipe would fight the system's back + // gesture and the keys nearest the edge. + gesturesEnabled = drawer.isOpen, + drawerContent = { + ModalDrawerSheet { + Text( + text = "Tally", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(start = 28.dp, top = 24.dp, end = 28.dp), + ) + // The version crossed JNI to get here, so seeing it means the library loaded. + Text( + text = "engine $version", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 28.dp, bottom = 16.dp), + ) + for ((target, label) in listOf(Screen.Calculator to "Calculator", Screen.Convert to "Convert")) { + NavigationDrawerItem( + label = { Text(label) }, + selected = app.screen == target, + onClick = { go(target) }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), + ) + } + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp, horizontal = 28.dp)) + NavigationDrawerItem( + label = { Text("Settings") }, + selected = app.screen == Screen.Settings, + onClick = { go(Screen.Settings) }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), + ) + } + }, + ) { + Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing)) { + when (app.screen) { + Screen.Calculator -> CalculatorScreen( + onMenu = openMenu, + onConvert = { app.open(Screen.Convert) }, + ) + Screen.Convert -> ConvertScreen( + onMenu = openMenu, + onCalculator = { app.open(Screen.Calculator) }, + ) + Screen.Settings -> SettingsScreen(app, onMenu = openMenu) + } + } + } + } +} + +/** The bar every screen has: the menu, a title, and that screen's own actions. */ +@Composable +internal fun TopRow(title: String, onMenu: () -> Unit, actions: @Composable RowScope.() -> Unit = {}) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().height(48.dp).padding(end = 4.dp), + ) { + IconButton(onClick = onMenu) { + Icon(Icons.Filled.Menu, contentDescription = "menu") + } + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.weight(1f)) + actions() + } +} + +@Composable +private fun SettingsScreen(app: AppViewModel, onMenu: () -> Unit) { + Column(modifier = Modifier.fillMaxSize()) { + TopRow(title = "Settings", onMenu = onMenu) + Text( + text = "Theme", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 8.dp), + ) + Column(modifier = Modifier.selectableGroup()) { + for ((choice, label) in listOf( + ThemeChoice.System to "Follow the device", + ThemeChoice.Light to "Light", + ThemeChoice.Dark to "Dark", + )) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .selectable( + selected = app.theme == choice, + onClick = { app.chooseTheme(choice) }, + role = Role.RadioButton, + ) + .padding(horizontal = 16.dp), + ) { + RadioButton(selected = app.theme == choice, onClick = null) + Text(text = label, modifier = Modifier.padding(start = 16.dp)) + } + } + } + } +} diff --git a/android/app/src/main/java/dev/lerch/tally/TallyEngine.kt b/android/app/src/main/java/dev/lerch/tally/TallyEngine.kt new file mode 100644 index 0000000..150d58e --- /dev/null +++ b/android/app/src/main/java/dev/lerch/tally/TallyEngine.kt @@ -0,0 +1,229 @@ +package dev.lerch.tally + +import org.json.JSONObject + +/** + * The native engine, as Kotlin sees it. + * + * These are the `Java_dev_lerch_tally_TallyEngine_*` entry points in + * `engine/src/jni.zig`. The library underneath them is the same one the CLI and the TUI + * are built from: nothing about arithmetic, units or money is reimplemented here, and + * nothing should be. If a calculation looks wrong, it is wrong in all three places. + * + * `System.loadLibrary` runs `JNI_OnLoad`, which proves the hand-written JNI function + * table against this JVM before anything else is allowed to happen. A failure there + * throws here, at startup, rather than crashing later inside an evaluation. + */ +internal object TallyEngine { + init { + System.loadLibrary("tally") + } + + const val MODE_STANDARD = 0 + const val MODE_PROGRAMMER = 1 + + /** The ABI this Kotlin was written against; see [checkAbi]. */ + const val EXPECTED_ABI_VERSION = 5 + + external fun sessionNew(): Long + + external fun sessionFree(handle: Long) + + /** Returns the result JSON, or null when the call could not be made at all. */ + external fun eval(handle: Long, expr: String, mode: Int): String? + + /** What [eval] would return, with nothing stored: no assignment, `Ans` unchanged. */ + external fun preview(handle: Long, expr: String, mode: Int): String? + + external fun unitCatalog(handle: Long): String? + + /** A value in every unit of [unit]'s category, as JSON. Stores nothing. */ + external fun convert(handle: Long, value: String, unit: String): String? + + /** Narrow or widen a session's rendering. Returns the `tally_status`, 0 for ok. */ + external fun configureDisplay(handle: Long, fractionDigits: Int, significantDigits: Int): Int + + /** The session's variables and `Ans`, exactly, as JSON; null if it could not be made. */ + external fun sessionSave(handle: Long): String? + + /** Put a saved state back. Returns the `tally_status`; anything but 0 changes nothing. */ + external fun sessionLoad(handle: Long, state: String): Int + + external fun versionString(): String? + + external fun abiVersion(): Int + + /** + * The library ships as a prebuilt `.so`, so it can fall out of step with this code. + * A mismatch means a struct layout or an argument order may have moved under us, + * which is a crash later rather than an error now, so it is checked once at startup. + */ + fun checkAbi() { + val actual = abiVersion() + require(actual == EXPECTED_ABI_VERSION) { + "libtally ABI version $actual, expected $EXPECTED_ABI_VERSION: " + + "rebuild with `zig build android`" + } + } +} + +/** + * One calculator with a memory: variables, the last answer, and the buffer its answers + * are written into. + * + * Closing it frees everything the native side holds. A session is not thread-safe by + * itself, which is deliberate rather than an oversight (the library takes no locks): + * [TallyViewModel] makes every call from one dedicated thread, which is the caller's + * half of that bargain. + */ +internal class TallySession : AutoCloseable { + private var handle: Long = TallyEngine.sessionNew() + + init { + require(handle != 0L) { "could not create a Tally session" } + } + + fun eval(expression: String, mode: Int = TallyEngine.MODE_STANDARD): TallyResult { + check(handle != 0L) { "session used after close" } + val json = TallyEngine.eval(handle, expression, mode) + ?: return TallyResult(ok = false, error = "the engine could not be reached") + return TallyResult.parse(json) + } + + /** + * The answer [eval] would give, with nothing stored. This is what the live result + * calls on every keystroke: typing `x = 7` must not assign `x` at each step, and the + * intermediate answers must not become `Ans`. + */ + fun preview(expression: String, mode: Int = TallyEngine.MODE_STANDARD): TallyResult { + check(handle != 0L) { "session used after close" } + val json = TallyEngine.preview(handle, expression, mode) + ?: return TallyResult(ok = false, error = "the engine could not be reached") + return TallyResult.parse(json) + } + + /** The unit categories and their units, straight from the engine's own tables. */ + fun unitCatalog(): String? { + check(handle != 0L) { "session used after close" } + return TallyEngine.unitCatalog(handle) + } + + /** [value] - an expression - in every unit of [unit]'s category. Stores nothing. */ + fun convert(value: String, unit: String): ConversionTable { + check(handle != 0L) { "session used after close" } + val json = TallyEngine.convert(handle, value, unit) + ?: return ConversionTable(ok = false, error = "the engine could not be reached") + return ConversionTable.parse(json) + } + + /** How many digits this session renders (NFR-9.9: the frontend's call, not the engine's). */ + fun configureDisplay(fractionDigits: Int, significantDigits: Int) { + check(handle != 0L) { "session used after close" } + check(TallyEngine.configureDisplay(handle, fractionDigits, significantDigits) == 0) { + "display budget refused: $fractionDigits fraction, $significantDigits significant" + } + } + + /** The variables and `Ans`, exactly, for keeping across a restart (design 6.7). */ + fun save(): String? { + check(handle != 0L) { "session used after close" } + return TallyEngine.sessionSave(handle) + } + + /** + * Put a saved state back. False if it was refused, in which case the session is as it + * was: a state from an incompatible build is a fresh start, not a partial one. + */ + fun load(state: String): Boolean { + check(handle != 0L) { "session used after close" } + return TallyEngine.sessionLoad(handle, state) == 0 + } + + override fun close() { + if (handle != 0L) { + TallyEngine.sessionFree(handle) + handle = 0L + } + } +} + +/** + * A result, as the engine describes it. + * + * Parsed with `org.json`, which Android ships, rather than a serialization library: the + * payload is small and the shape is documented in design 6, so a dependency would buy + * nothing. + */ +internal data class TallyResult( + val ok: Boolean, + val display: String? = null, + /** True when no rounding happened anywhere in the value's history. */ + val exact: Boolean = false, + /** True when the TEXT is not the whole value, which is a different question. */ + val truncated: Boolean = false, + val error: String? = null, + val conversion: Conversion? = null, + val bases: Bases? = null, + val rows: Rows? = null, + val bits: Int? = null, + /** The engine's JSON exactly as it came back, which is what history saves. */ + val raw: String = "", +) { + internal data class Conversion(val input: String, val from: String, val to: String) + + internal data class Bases(val hex: String, val oct: String, val bin: String) + + internal data class Rows( + val decSigned: String, + val decUnsigned: String, + val hex: String, + val oct: String, + val bin: String, + val ascii: String, + ) + + companion object { + fun parse(json: String): TallyResult { + val root = try { + JSONObject(json) + } catch (e: Exception) { + return TallyResult(ok = false, error = "unreadable result: ${e.message}") + } + if (!root.optBoolean("ok", false)) { + return TallyResult(ok = false, error = root.optString("error", "evaluation failed"), raw = json) + } + return TallyResult( + raw = json, + ok = true, + display = root.optString("display"), + exact = root.optBoolean("exact", false), + truncated = root.optBoolean("truncated", false), + conversion = root.optJSONObject("conversion")?.let { + Conversion( + input = it.optString("input"), + from = it.optString("from"), + to = it.optString("to"), + ) + }, + bases = root.optJSONObject("bases")?.let { + Bases( + hex = it.optString("hex"), + oct = it.optString("oct"), + bin = it.optString("bin"), + ) + }, + rows = root.optJSONObject("rows")?.let { + Rows( + decSigned = it.optString("dec_signed"), + decUnsigned = it.optString("dec_unsigned"), + hex = it.optString("hex"), + oct = it.optString("oct"), + bin = it.optString("bin"), + ascii = it.optString("ascii"), + ) + }, + bits = if (root.has("bits")) root.optInt("bits") else null, + ) + } + } +} diff --git a/android/app/src/main/java/dev/lerch/tally/TallyViewModel.kt b/android/app/src/main/java/dev/lerch/tally/TallyViewModel.kt new file mode 100644 index 0000000..42c3ba9 --- /dev/null +++ b/android/app/src/main/java/dev/lerch/tally/TallyViewModel.kt @@ -0,0 +1,297 @@ +package dev.lerch.tally + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.concurrent.Executors + +/** + * The standard screen's state: one expression, its live answer, and a history. + * + * Every engine call runs on one dedicated thread. Off the main thread, because a preview + * of `factorial(5000)` has to cost a frame rather than an ANR; and *one* thread, because + * a session takes no locks and serialising its calls is the caller's job (design 6.1, + * rule 3). A single-threaded executor does that by construction, including at teardown: + * the session is closed by a task queued behind whatever is still running. + * + * It reopens as it was left (design 9.2): the expression and its cursor, the answer on + * display, the history, and - through the engine's own save - every variable and `Ans`, + * exactly. The load is the first task on the engine thread, so nothing can evaluate + * against a session that has not been restored yet. + */ +internal class TallyViewModel(app: Application) : AndroidViewModel(app) { + private val prefs = AppPrefs(app) + private val engineThread = Executors.newSingleThreadExecutor { task -> Thread(task, "tally-engine") } + private val engine = engineThread.asCoroutineDispatcher() + private val session = TallySession() + + /** The engine's version. Shown where there is nothing else to show. */ + val engineVersion: String = TallyEngine.versionString() ?: "unknown" + + internal data class Entry(val expression: String, val result: TallyResult) + + /** The expression being edited, with its cursor. */ + var field by mutableStateOf(TextFieldValue("")) + private set + + /** The live answer for [field], or null while it does not evaluate. */ + var preview by mutableStateOf(null) + private set + + /** The entry just committed with `=`, shown as the answer until the next edit. */ + var committed by mutableStateOf(null) + private set + + /** Why the last `=` failed, shown in place of the answer. */ + var error by mutableStateOf(null) + private set + + /** True while the system keyboard is the input instead of the pad. */ + var typing by mutableStateOf(false) + private set + + var showingHistory by mutableStateOf(false) + private set + + /** Newest last. */ + val history = mutableStateListOf() + + private var previewJob: Job? = null + + init { + TallyEngine.checkAbi() + restore() + } + + // -- Persistence -- + + private fun restore() { + // The engine first, queued before anything else can be: variables and `Ans`. + prefs.calcEngine?.let { state -> + viewModelScope.launch { withContext(engine) { session.load(state) } } + } + history.addAll(CalcCodec.decodeEntries(prefs.calcHistory).map(::entryOf)) + committed = CalcCodec.decodeEntry(prefs.calcCommitted)?.let(::entryOf) + CalcCodec.decodeField(prefs.calcField)?.let { saved -> + field = TextFieldValue(saved.text, TextRange(saved.selectionStart, saved.selectionEnd)) + schedulePreview() + } + } + + private fun entryOf(saved: SavedEntry) = Entry(saved.expression, TallyResult.parse(saved.resultJson)) + + private fun savedOf(entry: Entry) = SavedEntry(entry.expression, entry.result.raw) + + private fun saveField() { + prefs.calcField = CalcCodec.encodeField(SavedField(field.text, field.selection.start, field.selection.end)) + } + + private fun saveCommitted() { + prefs.calcCommitted = CalcCodec.encodeEntry(committed?.let(::savedOf)) + } + + private fun saveHistory() { + prefs.calcHistory = CalcCodec.encodeEntries(history.map(::savedOf)) + } + + // -- Editing -- + + /** + * Put [text] at the cursor, replacing any selection. + * + * After a commit the display holds an answer, not an expression. A key that + * [continues] one - an operator, `^`, `x^2` - picks that answer up as `Ans`, so `= 14` + * then `*` reads as `Ans *`. Anything else starts a fresh expression. + */ + fun insert(text: String, continues: Boolean = false) { + var current = field + if (committed != null) { + current = if (continues) TextFieldValue("Ans", TextRange(3)) else TextFieldValue("") + committed = null + } + val start = current.selection.min + val end = current.selection.max + val next = current.text.replaceRange(start, end, text) + edit(TextFieldValue(next, TextRange(start + text.length))) + } + + /** + * Delete the character before the cursor, or the selection. + * + * Straight after a commit there is nothing to delete, so it brings the committed + * expression back for editing instead - the usual way to fix a typo you only noticed + * once you saw the answer. + */ + fun backspace() { + committed?.let { entry -> + committed = null + edit(TextFieldValue(entry.expression, TextRange(entry.expression.length))) + return + } + val current = field + val start = current.selection.min + val end = current.selection.max + if (start != end) { + edit(TextFieldValue(current.text.removeRange(start, end), TextRange(start))) + } else if (start > 0) { + edit(TextFieldValue(current.text.removeRange(start - 1, start), TextRange(start - 1))) + } + } + + fun clear() { + committed = null + edit(TextFieldValue("")) + } + + /** Edits that come from the field itself: the system keyboard, or a tap moving the cursor. */ + fun onFieldChange(value: TextFieldValue) { + // The field wraps, so Enter on a keyboard arrives as a newline rather than as the + // Done action. An expression has no use for one: it means `=`. + if ('\n' in value.text) { + val text = value.text.replace("\n", "") + edit(TextFieldValue(text, TextRange(text.length))) + stopTyping() + commit() + return + } + if (value.text != field.text) committed = null + edit(value) + } + + private fun edit(value: TextFieldValue) { + val changed = value.text != field.text + field = value + // Every edit, cursor moves included: cheap, asynchronous, and the only way the + // field is exactly as left when the process is killed without warning. + saveField() + saveCommitted() + if (changed) { + error = null + schedulePreview() + } + } + + // -- Evaluating -- + + /** + * Ask for the answer to what is in the field, without storing anything. + * + * A newer keystroke cancels an older request. One that has not started is dropped + * from the queue; one already running in native code finishes, and its answer is + * thrown away because the field no longer matches. + */ + private fun schedulePreview() { + previewJob?.cancel() + val text = field.text + if (text.isBlank()) { + preview = null + return + } + previewJob = viewModelScope.launch { + val result = withContext(engine) { session.preview(text) } + if (field.text == text) { + // An unfinished expression - `2 +` - is an error on every other keystroke. + // Showing nothing beats flashing red while someone is still typing. + preview = result.takeIf { it.ok } + } + } + } + + /** `=`: evaluate for real, store what it assigns, and make it the answer. */ + fun commit() { + val text = field.text.trim() + if (text.isEmpty()) return + commitText(text) + } + + /** + * Store the expression - or, with nothing typed, the last answer - in [name]. + * + * The pad's long-press on a variable key: a calculator's STO, written as the + * assignment the engine already understands. + */ + fun storeInto(name: String) { + val text = if (committed != null) "" else field.text.trim() + commitText("$name = ${text.ifEmpty { "Ans" }}") + } + + private fun commitText(text: String) { + previewJob?.cancel() + viewModelScope.launch { + // Saved in the same trip to the engine thread as the evaluation, so the saved + // variables and `Ans` are exactly the ones this answer produced. + val (result, state) = withContext(engine) { + val result = session.eval(text) + result to (if (result.ok) session.save() else null) + } + if (result.ok) { + val entry = Entry(text, result) + history.add(entry) + committed = entry + field = TextFieldValue("") + preview = null + error = null + state?.let { prefs.calcEngine = it } + saveHistory() + saveCommitted() + saveField() + } else { + error = result.error + } + } + } + + // -- History -- + + fun openHistory() { + showingHistory = true + } + + fun closeHistory() { + showingHistory = false + } + + /** Put an old expression back in the field, ready to edit or re-run. */ + fun recall(entry: Entry) { + committed = null + edit(TextFieldValue(entry.expression, TextRange(entry.expression.length))) + showingHistory = false + } + + /** + * Clear the list. Variables and `Ans` are the engine's and are kept: history is a + * record of what was typed, not where the values live. + */ + fun clearHistory() { + history.clear() + saveHistory() + } + + // -- The system keyboard -- + + fun startTyping() { + typing = true + } + + fun stopTyping() { + typing = false + } + + override fun onCleared() { + // Queued, not called: anything still running on the engine thread finishes + // against a live session, and the free happens after it. + engineThread.execute { session.close() } + engineThread.shutdown() + super.onCleared() + } +} diff --git a/android/app/src/main/java/dev/lerch/tally/Units.kt b/android/app/src/main/java/dev/lerch/tally/Units.kt new file mode 100644 index 0000000..dfa8258 --- /dev/null +++ b/android/app/src/main/java/dev/lerch/tally/Units.kt @@ -0,0 +1,113 @@ +package dev.lerch.tally + +import org.json.JSONObject + +/** + * The engine's unit tables and conversions, as Kotlin sees them. + * + * Nothing here knows a unit. The catalogue is `tally_unit_catalog` parsed, and every + * number is `tally_convert` rendered; Kotlin only decides what to put where. + */ +internal data class UnitInfo( + /** The engine's canonical name, which is also what is passed back to it. */ + val name: String, + /** The engine's display name ("Meter per second" for `m/s`). */ + val longName: String, + val aliases: List, + val category: String, + val categoryLabel: String, +) { + /** For the picker's search: does the name, display name, category or any alias contain [query]? */ + fun matches(query: String): Boolean { + val q = query.trim() + if (q.isEmpty()) return true + return name.contains(q, ignoreCase = true) || + longName.contains(q, ignoreCase = true) || + categoryLabel.contains(q, ignoreCase = true) || + aliases.any { it.contains(q, ignoreCase = true) } + } +} + +internal data class UnitCategoryInfo( + val name: String, + val label: String, + val units: List, + /** The engine's base unit for the category, where a never-visited category starts. */ + val baseUnit: String = "", +) + +internal object UnitCatalog { + fun parse(json: String?): List { + if (json == null) return emptyList() + val root = try { + JSONObject(json) + } catch (e: Exception) { + return emptyList() + } + val categories = root.optJSONArray("categories") ?: return emptyList() + return (0 until categories.length()).map { i -> + val category = categories.getJSONObject(i) + val name = category.optString("name") + val label = category.optString("label") + val units = category.optJSONArray("units") + UnitCategoryInfo( + name = name, + label = label, + baseUnit = category.optString("base_unit"), + units = (0 until (units?.length() ?: 0)).map { j -> + val unit = units!!.getJSONObject(j) + val aliasArray = unit.optJSONArray("aliases") + val aliases = (0 until (aliasArray?.length() ?: 0)).map { aliasArray!!.getString(it) } + UnitInfo( + name = unit.optString("name"), + longName = unit.optString("label"), + aliases = aliases, + category = name, + categoryLabel = label, + ) + }, + ) + } + } +} + +/** One value in every unit of a category: `tally_convert`'s answer. */ +internal data class ConversionTable( + val ok: Boolean, + val input: String = "", + val from: String = "", + val category: String = "", + val rows: List = emptyList(), + val error: String? = null, +) { + internal data class Row(val unit: String, val display: String?, val exact: Boolean, val error: String?) + + companion object { + fun parse(json: String): ConversionTable { + val root = try { + JSONObject(json) + } catch (e: Exception) { + return ConversionTable(ok = false, error = "unreadable result: ${e.message}") + } + if (!root.optBoolean("ok", false)) { + return ConversionTable(ok = false, error = root.optString("error", "conversion failed")) + } + val results = root.optJSONArray("results") + return ConversionTable( + ok = true, + input = root.optString("input"), + from = root.optString("from"), + category = root.optString("category"), + rows = (0 until (results?.length() ?: 0)).map { i -> + val row = results!!.getJSONObject(i) + Row( + unit = row.optString("unit"), + display = if (row.has("display")) row.optString("display") else null, + exact = row.optBoolean("exact", false), + error = if (row.has("error")) row.optString("error") else null, + ) + }, + ) + } + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..89fee35 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_monochrome.xml b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..ede3d51 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6e90e74 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..c78bee3 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/values-night/themes.xml b/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..6d4b117 --- /dev/null +++ b/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,6 @@ + + + +