initial android (ai generated)
This commit is contained in:
parent
b86ba42811
commit
a9b7e0fb1d
31 changed files with 3990 additions and 4 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -2,6 +2,13 @@
|
||||||
.zig-cache/
|
.zig-cache/
|
||||||
zig-out/
|
zig-out/
|
||||||
|
|
||||||
|
# Gradle and Android build artifacts
|
||||||
|
android/.gradle/
|
||||||
|
android/.kotlin/
|
||||||
|
android/build/
|
||||||
|
android/app/build/
|
||||||
|
android/local.properties
|
||||||
|
|
||||||
# Temporary files
|
# Temporary files
|
||||||
.tmp/
|
.tmp/
|
||||||
|
|
||||||
|
|
|
||||||
53
.mise.toml
53
.mise.toml
|
|
@ -3,5 +3,58 @@ zig = "0.16.0"
|
||||||
zls = "0.16.0"
|
zls = "0.16.0"
|
||||||
"github:j178/prek" = "0.4.1"
|
"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"]
|
[tools."github:DonIsaac/zlint"]
|
||||||
version = "0.9.0"
|
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'
|
||||||
|
|
|
||||||
70
android/README.md
Normal file
70
android/README.md
Normal file
|
|
@ -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.
|
||||||
230
android/SETUP.md
Normal file
230
android/SETUP.md
Normal file
|
|
@ -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 <apk> && 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.
|
||||||
80
android/app/build.gradle.kts
Normal file
80
android/app/build.gradle.kts
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
android/app/src/main/AndroidManifest.xml
Normal file
26
android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- No permissions. The calculator does no I/O, reaches no network, and reads nothing
|
||||||
|
off the device: the engine underneath it is a pure function. -->
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:label="Tally"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Tally">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="Tally"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
81
android/app/src/main/java/dev/lerch/tally/AppPrefs.kt
Normal file
81
android/app/src/main/java/dev/lerch/tally/AppPrefs.kt
Normal file
|
|
@ -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<String?, String?, String?>
|
||||||
|
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 <reified E : Enum<E>> enumOr(name: String?, fallback: E): E =
|
||||||
|
enumValues<E>().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"
|
||||||
|
}
|
||||||
|
}
|
||||||
105
android/app/src/main/java/dev/lerch/tally/CalcState.kt
Normal file
105
android/app/src/main/java/dev/lerch/tally/CalcState.kt
Normal file
|
|
@ -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<SavedEntry>): 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<SavedEntry> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
392
android/app/src/main/java/dev/lerch/tally/CalculatorScreen.kt
Normal file
392
android/app/src/main/java/dev/lerch/tally/CalculatorScreen.kt
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
476
android/app/src/main/java/dev/lerch/tally/ConvertScreen.kt
Normal file
476
android/app/src/main/java/dev/lerch/tally/ConvertScreen.kt
Normal file
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
221
android/app/src/main/java/dev/lerch/tally/ConvertViewModel.kt
Normal file
221
android/app/src/main/java/dev/lerch/tally/ConvertViewModel.kt
Normal file
|
|
@ -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<UnitIndex?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var state by mutableStateOf<Converter?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** The current value in every unit of its category, from the engine. */
|
||||||
|
private var table by mutableStateOf<ConversionTable?>(null)
|
||||||
|
|
||||||
|
/** What [table] was computed from; a table for anything else is stale. */
|
||||||
|
private var tableFor: Pair<String, String>? = null
|
||||||
|
|
||||||
|
var picker by mutableStateOf<Picker?>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
283
android/app/src/main/java/dev/lerch/tally/Converter.kt
Normal file
283
android/app/src/main/java/dev/lerch/tally/Converter.kt
Normal file
|
|
@ -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<String>,
|
||||||
|
/** 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<String, CategoryMemory>,
|
||||||
|
/**
|
||||||
|
* 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<String> = emptyList()
|
||||||
|
var savedCurrent: String? = null
|
||||||
|
val memory = LinkedHashMap<String, CategoryMemory>()
|
||||||
|
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<String> {
|
||||||
|
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<UnitCategoryInfo>) {
|
||||||
|
/** In the engine's table order, which is the order units are listed in. */
|
||||||
|
val categories: Map<String, UnitCategoryInfo> = catalog.associateBy { it.name }
|
||||||
|
val categoryNames: List<String> = catalog.map { it.name }
|
||||||
|
private val byName: Map<String, UnitInfo> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
322
android/app/src/main/java/dev/lerch/tally/Keypad.kt
Normal file
322
android/app/src/main/java/dev/lerch/tally/Keypad.kt
Normal file
|
|
@ -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<List<Key>> = 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<List<Key>> = 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<List<Key>> = 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<List<Key>> = 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<List<Key>>,
|
||||||
|
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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
233
android/app/src/main/java/dev/lerch/tally/MainActivity.kt
Normal file
233
android/app/src/main/java/dev/lerch/tally/MainActivity.kt
Normal file
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
229
android/app/src/main/java/dev/lerch/tally/TallyEngine.kt
Normal file
229
android/app/src/main/java/dev/lerch/tally/TallyEngine.kt
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
297
android/app/src/main/java/dev/lerch/tally/TallyViewModel.kt
Normal file
297
android/app/src/main/java/dev/lerch/tally/TallyViewModel.kt
Normal file
|
|
@ -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<TallyResult?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** The entry just committed with `=`, shown as the answer until the next edit. */
|
||||||
|
var committed by mutableStateOf<Entry?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Why the last `=` failed, shown in place of the answer. */
|
||||||
|
var error by mutableStateOf<String?>(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<Entry>()
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
113
android/app/src/main/java/dev/lerch/tally/Units.kt
Normal file
113
android/app/src/main/java/dev/lerch/tally/Units.kt
Normal file
|
|
@ -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<String>,
|
||||||
|
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<UnitInfo>,
|
||||||
|
/** 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<UnitCategoryInfo> {
|
||||||
|
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<Row> = 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,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
24
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Tally marks: four strokes and the diagonal that closes the five.
|
||||||
|
|
||||||
|
Adaptive icon foreground, 108dp with the artwork inside the 66dp safe circle, so
|
||||||
|
no launcher mask (circle, squircle, teardrop) can clip it. Every endpoint is at
|
||||||
|
most 27dp from the centre; the round caps add 3.5dp. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:pathData="M38,36 L38,72 M47.33,36 L47.33,72 M56.67,36 L56.67,72 M66,36 L66,72"
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeWidth="7"
|
||||||
|
android:strokeLineCap="round" />
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:pathData="M30,64 L76,44"
|
||||||
|
android:strokeColor="#FFD8E4"
|
||||||
|
android:strokeWidth="7"
|
||||||
|
android:strokeLineCap="round" />
|
||||||
|
</vector>
|
||||||
15
android/app/src/main/res/drawable/ic_launcher_monochrome.xml
Normal file
15
android/app/src/main/res/drawable/ic_launcher_monochrome.xml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- The same marks in one colour, for Android 13+ themed icons, which tint this layer
|
||||||
|
to match the wallpaper and ignore the background. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:pathData="M38,36 L38,72 M47.33,36 L47.33,72 M56.67,36 L56.67,72 M66,36 L66,72 M30,64 L76,44"
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeWidth="7"
|
||||||
|
android:strokeLineCap="round" />
|
||||||
|
</vector>
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- minSdk is 26, so every supported device takes this adaptive icon and no bitmap
|
||||||
|
fallbacks are needed. -->
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||||
|
</adaptive-icon>
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||||
|
</adaptive-icon>
|
||||||
6
android/app/src/main/res/values-night/themes.xml
Normal file
6
android/app/src/main/res/values-night/themes.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- The window behind the first frame, in a dark device. Compose takes over from
|
||||||
|
there, following the app's own theme setting. -->
|
||||||
|
<style name="Theme.Tally" parent="android:Theme.Material.NoActionBar" />
|
||||||
|
</resources>
|
||||||
5
android/app/src/main/res/values/ic_launcher_colors.xml
Normal file
5
android/app/src/main/res/values/ic_launcher_colors.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Material's baseline primary, the same purple the app's light theme uses. -->
|
||||||
|
<color name="ic_launcher_background">#6750A4</color>
|
||||||
|
</resources>
|
||||||
6
android/app/src/main/res/values/themes.xml
Normal file
6
android/app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Compose supplies the real theming; this is the window background and the splash
|
||||||
|
colour before the first frame, which have to come from resources. -->
|
||||||
|
<style name="Theme.Tally" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
</resources>
|
||||||
70
android/app/src/test/java/dev/lerch/tally/CalcCodecTest.kt
Normal file
70
android/app/src/test/java/dev/lerch/tally/CalcCodecTest.kt
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
package dev.lerch.tally
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** The calculator's saved screen state (design 9.2), on the JVM. */
|
||||||
|
class CalcCodecTest {
|
||||||
|
@Test
|
||||||
|
fun historyRoundTripsWhateverWasTyped() {
|
||||||
|
// Anything the system keyboard can produce, including the format's own separators.
|
||||||
|
val entries = listOf(
|
||||||
|
SavedEntry("2+3*4", "{\"ok\":true,\"display\":\"14\"}"),
|
||||||
|
SavedEntry("x = 7\twith a tab", "{\"ok\":true}"),
|
||||||
|
SavedEntry("a\\b \n line \r", "{\"display\":\"\\u00e9\"}"),
|
||||||
|
SavedEntry("caf\u00e9 \u03c0", ""),
|
||||||
|
)
|
||||||
|
assertEquals(entries, CalcCodec.decodeEntries(CalcCodec.encodeEntries(entries)))
|
||||||
|
assertEquals(emptyList<SavedEntry>(), CalcCodec.decodeEntries(CalcCodec.encodeEntries(emptyList())))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onlyTheNewestEntriesAreKept() {
|
||||||
|
val many = (1..CalcCodec.MAX_HISTORY + 20).map { SavedEntry("$it", "{}") }
|
||||||
|
val kept = CalcCodec.decodeEntries(CalcCodec.encodeEntries(many))
|
||||||
|
assertEquals(CalcCodec.MAX_HISTORY, kept.size)
|
||||||
|
assertEquals("21", kept.first().expression)
|
||||||
|
assertEquals("${CalcCodec.MAX_HISTORY + 20}", kept.last().expression)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anythingThatIsNotASavedListIsAFreshStart() {
|
||||||
|
assertTrue(CalcCodec.decodeEntries(null).isEmpty())
|
||||||
|
assertTrue(CalcCodec.decodeEntries("").isEmpty())
|
||||||
|
assertTrue(CalcCodec.decodeEntries("something else\n1\t2\n").isEmpty())
|
||||||
|
// A damaged line is dropped; the good ones around it survive.
|
||||||
|
val damaged = "entries v1\n1\t{}\nno tab here\nbad\\q\t{}\n2\t{}\n"
|
||||||
|
assertEquals(listOf("1", "2"), CalcCodec.decodeEntries(damaged).map { it.expression })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theAnswerOnDisplayIsOneEntryOrNone() {
|
||||||
|
val entry = SavedEntry("6*7", "{\"display\":\"42\"}")
|
||||||
|
assertEquals(entry, CalcCodec.decodeEntry(CalcCodec.encodeEntry(entry)))
|
||||||
|
assertNull(CalcCodec.encodeEntry(null))
|
||||||
|
assertNull(CalcCodec.decodeEntry(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theFieldKeepsItsCursorAndCannotPutItOutsideTheText() {
|
||||||
|
val field = SavedField("sqrt(2)\t+ 1", 5, 7)
|
||||||
|
assertEquals(field, CalcCodec.decodeField(CalcCodec.encodeField(field)))
|
||||||
|
|
||||||
|
assertEquals(SavedField("abc", 3, 0), CalcCodec.decodeField("field v1\t99\t-4\tabc"))
|
||||||
|
assertNull(CalcCodec.decodeField(null))
|
||||||
|
assertNull(CalcCodec.decodeField("field v2\t0\t0\tabc"))
|
||||||
|
assertNull(CalcCodec.decodeField("field v1\tx\t0\tabc"))
|
||||||
|
assertNull(CalcCodec.decodeField("field v1\t0\t0\tbad\\"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun escapingIsExactlyReversible() {
|
||||||
|
for (text in listOf("", "\\", "\\\\t", "\t\n\r", "plain", "\\n literally")) {
|
||||||
|
assertEquals(text, CalcCodec.unescape(CalcCodec.escape(text)))
|
||||||
|
}
|
||||||
|
assertNull(CalcCodec.unescape("trailing\\"))
|
||||||
|
assertNull(CalcCodec.unescape("\\x"))
|
||||||
|
}
|
||||||
|
}
|
||||||
249
android/app/src/test/java/dev/lerch/tally/ConverterTest.kt
Normal file
249
android/app/src/test/java/dev/lerch/tally/ConverterTest.kt
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
package dev.lerch.tally
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The converter's rules (design 9.6), on the JVM with no device and no engine.
|
||||||
|
*
|
||||||
|
* The catalogue below is a hand-built subset in the engine's table order. Renderings
|
||||||
|
* the engine would supply are passed in as strings, the way the view model passes them.
|
||||||
|
*/
|
||||||
|
class ConverterTest {
|
||||||
|
private fun unit(name: String, category: String) = UnitInfo(name, name, emptyList(), category, category)
|
||||||
|
|
||||||
|
private fun category(name: String, base: String, vararg units: String) =
|
||||||
|
UnitCategoryInfo(name, name.replaceFirstChar { it.uppercaseChar() }, units.map { unit(it, name) }, base)
|
||||||
|
|
||||||
|
// Table order, as the engine lists them: not the preferred first-run order.
|
||||||
|
private val units = UnitIndex(
|
||||||
|
listOf(
|
||||||
|
category("length", "m", "mm", "m", "km", "mi"),
|
||||||
|
category("mass", "kg", "g", "kg", "lb"),
|
||||||
|
category("temperature", "C", "C", "F", "K"),
|
||||||
|
category("time", "s", "s", "min"),
|
||||||
|
category("speed", "m/s", "m/s", "km/h"),
|
||||||
|
category("volume", "L", "mL", "L"),
|
||||||
|
category("angle", "rad", "rad", "deg"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aFirstRunPutsTheEverydayCategoriesFirstAndListsEveryOne() {
|
||||||
|
val c = Converter.initial(units)
|
||||||
|
assertEquals(listOf("temperature", "length", "mass", "volume", "speed", "time", "angle"), c.order)
|
||||||
|
assertEquals("temperature", c.current)
|
||||||
|
// A category never visited starts at 1 of its base unit.
|
||||||
|
assertEquals(CategoryMemory("1", "C", "C"), c.here)
|
||||||
|
assertTrue(c.fresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun eachCategoryComesBackExactlyAsItWasLeft() {
|
||||||
|
var c = Converter.initial(units)
|
||||||
|
c = c.digit("9", null).digit("8", null).point(null).digit("6", null)
|
||||||
|
c = c.showUnit("F", units).digit("9", "37").digit("8", null).point(null).digit("6", null)
|
||||||
|
assertEquals(CategoryMemory("98.6", "F", "F"), c.here)
|
||||||
|
|
||||||
|
// 180 miles, Seattle to Portland, for telling foreigners.
|
||||||
|
c = c.selectCategory("length", units).showUnit("mi", units)
|
||||||
|
c = c.digit("1", "0.000621371192").digit("8", null).digit("0", null)
|
||||||
|
assertEquals(CategoryMemory("180", "mi", "mi"), c.here)
|
||||||
|
|
||||||
|
c = c.selectCategory("temperature", units)
|
||||||
|
assertEquals("98.6", c.here.value)
|
||||||
|
assertEquals("F", c.here.sourceUnit)
|
||||||
|
assertTrue("restored, so the next digit starts a new number", c.fresh)
|
||||||
|
|
||||||
|
c = c.selectCategory("length", units)
|
||||||
|
assertEquals(CategoryMemory("180", "mi", "mi"), c.here)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theRowIsMostRecentFirstAndNeverLosesACategory() {
|
||||||
|
var c = Converter.initial(units)
|
||||||
|
c = c.selectCategory("angle", units).selectCategory("mass", units)
|
||||||
|
assertEquals(listOf("mass", "angle", "temperature", "length", "volume", "speed", "time"), c.order)
|
||||||
|
assertEquals(units.categoryNames.toSet(), c.order.toSet())
|
||||||
|
|
||||||
|
// A name the engine does not have changes nothing.
|
||||||
|
assertEquals(c, c.selectCategory("currency", units))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showingAnotherUnitChangesNoValueSoARoundTripIsExact() {
|
||||||
|
var c = Converter.initial(units).selectCategory("length", units)
|
||||||
|
c = c.showUnit("km", units).digit("9", "1").digit("8", null).point(null).digit("6", null)
|
||||||
|
c = c.showUnit("mi", units).showUnit("km", units)
|
||||||
|
assertEquals(CategoryMemory("98.6", "km", "km"), c.here)
|
||||||
|
assertTrue(c.fresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun typingAfterATapEditsTheNumberOnScreenInTheUnitOnScreen() {
|
||||||
|
var c = Converter.initial(units).selectCategory("length", units).showUnit("km", units)
|
||||||
|
c = c.digit("9", "1").digit("8", null).point(null).digit("6", null)
|
||||||
|
c = c.showUnit("mi", units)
|
||||||
|
|
||||||
|
// Backspace edits the mile figure you can see, not 98.6 read as miles.
|
||||||
|
val back = c.backspace("61.2671995546")
|
||||||
|
assertEquals(CategoryMemory("61.267199554", "mi", "mi"), back.here)
|
||||||
|
assertFalse(back.fresh)
|
||||||
|
|
||||||
|
// A digit replaces it, and the value is now in miles.
|
||||||
|
assertEquals(CategoryMemory("5", "mi", "mi"), c.digit("5", "61.2671995546").here)
|
||||||
|
|
||||||
|
// Grouping commas in a rendering are display, not syntax.
|
||||||
|
assertEquals("-98600", c.showUnit("m", units).toggleSign("98,600").here.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun withoutARenderingYetEditingStaysInTheTypedUnit() {
|
||||||
|
var c = Converter.initial(units).selectCategory("length", units).showUnit("km", units)
|
||||||
|
c = c.digit("4", "1").digit("2", null).showUnit("mi", units)
|
||||||
|
// The engine has not answered, so 42 is still kilometres, not 42 miles.
|
||||||
|
assertEquals(CategoryMemory("4", "km", "km"), c.backspace(null).here)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theDigitRules() {
|
||||||
|
var c = Converter.initial(units)
|
||||||
|
c = c.digit("5", null)
|
||||||
|
assertEquals("5", c.here.value)
|
||||||
|
c = c.digit("0", null)
|
||||||
|
assertEquals("50", c.here.value)
|
||||||
|
assertEquals("7", c.clear(null).digit("0", null).digit("7", null).here.value)
|
||||||
|
|
||||||
|
assertEquals("0.", Converter.initial(units).point(null).here.value)
|
||||||
|
assertEquals("50.", c.point(null).here.value)
|
||||||
|
assertEquals("one point is enough", "50.", c.point(null).point(null).here.value)
|
||||||
|
assertEquals("", c.clear(null).here.value)
|
||||||
|
assertEquals("-50", c.toggleSign(null).here.value)
|
||||||
|
assertEquals("50", c.toggleSign(null).toggleSign(null).here.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSearchResultInAnUnvisitedCategoryStartsInThatUnit() {
|
||||||
|
val c = Converter.initial(units).showUnit("km/h", units)
|
||||||
|
assertEquals("speed", c.current)
|
||||||
|
assertEquals("speed", c.order.first())
|
||||||
|
assertEquals(CategoryMemory("1", "km/h", "km/h"), c.here)
|
||||||
|
|
||||||
|
// In a visited one it shows that unit and keeps the remembered value.
|
||||||
|
var d = Converter.initial(units).selectCategory("length", units)
|
||||||
|
d = d.showUnit("mi", units).digit("1", "0.0006").digit("8", null).digit("0", null)
|
||||||
|
d = d.selectCategory("mass", units).showUnit("km", units)
|
||||||
|
assertEquals(CategoryMemory("180", "mi", "km"), d.here)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aStateSurvivesBeingSavedAndRestored() {
|
||||||
|
var c = Converter.initial(units).digit("9", null).digit("8", null).point(null).digit("6", null)
|
||||||
|
c = c.showUnit("F", units).withShownDisplay("209.48")
|
||||||
|
c = c.selectCategory("length", units).showUnit("mi", units).digit("1", "0.0006")
|
||||||
|
val restored = Converter.restore(c.serialize(), units)!!
|
||||||
|
assertEquals(c.order, restored.order)
|
||||||
|
assertEquals(c.current, restored.current)
|
||||||
|
assertEquals(c.memory, restored.memory)
|
||||||
|
assertTrue(restored.fresh)
|
||||||
|
assertEquals("209.48 F", restored.memory.getValue("temperature").summary())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRestoreKeepsOnlyWhatTheEngineStillHas() {
|
||||||
|
val saved = listOf(
|
||||||
|
"converter v1",
|
||||||
|
"order\tcurrency,mass,length",
|
||||||
|
"current\tcurrency",
|
||||||
|
"mem\tmass\t3\tlb\tkg\t1.36",
|
||||||
|
"mem\tlength\t5\tlb\tlb\t",
|
||||||
|
"mem\tcurrency\t1\tUSD\tEUR\t",
|
||||||
|
).joinToString("\n")
|
||||||
|
val c = Converter.restore(saved, units)!!
|
||||||
|
// Unknown categories gone, missing ones appended, current repaired.
|
||||||
|
assertEquals(listOf("mass", "length", "temperature", "volume", "speed", "time", "angle"), c.order)
|
||||||
|
assertEquals("mass", c.current)
|
||||||
|
assertEquals(CategoryMemory("3", "lb", "kg", "1.36"), c.here)
|
||||||
|
// A memory whose unit is not in its category is dropped rather than trusted.
|
||||||
|
assertNull(c.memory["length"])
|
||||||
|
|
||||||
|
assertNull(Converter.restore(null, units))
|
||||||
|
assertNull(Converter.restore("something else", units))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theOldSingleValueMigrates() {
|
||||||
|
val c = Converter.fromLegacy("98.6", "F", "C", units)!!
|
||||||
|
assertEquals("temperature", c.current)
|
||||||
|
assertEquals(CategoryMemory("98.6", "F", "C"), c.here)
|
||||||
|
assertEquals(units.categoryNames.size, c.order.size)
|
||||||
|
|
||||||
|
assertNull(Converter.fromLegacy(null, "F", "C", units))
|
||||||
|
assertNull(Converter.fromLegacy("1", "smoot", null, units))
|
||||||
|
// A shown unit from another category falls back to the typed one.
|
||||||
|
assertEquals("F", Converter.fromLegacy("98.6", "F", "km", units)!!.here.shownUnit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aChipSaysWhereATapLands() {
|
||||||
|
assertEquals("98.6 F", CategoryMemory("98.6", "F", "F").summary())
|
||||||
|
assertEquals("37 C", CategoryMemory("98.6", "F", "C", "37").summary())
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Scroll position --
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun eachCategoryKeepsItsOwnScrollPosition() {
|
||||||
|
var c = Converter.initial(units).selectCategory("length", units)
|
||||||
|
c = c.withScroll("length", "km", 37, units)
|
||||||
|
c = c.selectCategory("mass", units).withScroll("mass", "lb", 5, units)
|
||||||
|
c = c.selectCategory("length", units)
|
||||||
|
assertEquals("km", c.here.scrollUnit)
|
||||||
|
assertEquals(37, c.here.scrollOffset)
|
||||||
|
assertEquals("lb", c.memory.getValue("mass").scrollUnit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nothingButScrollingMovesTheList() {
|
||||||
|
var c = Converter.initial(units).selectCategory("length", units).withScroll("length", "km", 12, units)
|
||||||
|
// Typing, showing another unit, and typing in that unit all leave it where it is.
|
||||||
|
c = c.digit("1", null).digit("8", null).digit("0", null)
|
||||||
|
c = c.showUnit("mi", units).digit("7", "111")
|
||||||
|
c = c.backspace(null).toggleSign(null).clear(null)
|
||||||
|
assertEquals("km", c.here.scrollUnit)
|
||||||
|
assertEquals(12, c.here.scrollOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aScrollAnchorOutsideItsCategoryMeansTheTop() {
|
||||||
|
val c = Converter.initial(units).selectCategory("length", units)
|
||||||
|
assertNull(c.withScroll("length", "lb", 40, units).here.scrollUnit)
|
||||||
|
assertEquals(0, c.withScroll("length", "lb", 40, units).here.scrollOffset)
|
||||||
|
assertNull(c.withScroll("length", null, 40, units).here.scrollUnit)
|
||||||
|
// A category never visited has nothing to record into.
|
||||||
|
assertEquals(c, c.withScroll("angle", "deg", 3, units))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aScrollPositionSurvivesARestartAndOlderSavesStillLoad() {
|
||||||
|
val c = Converter.initial(units).selectCategory("length", units).withScroll("length", "mi", 21, units)
|
||||||
|
val restored = Converter.restore(c.serialize(), units)!!
|
||||||
|
assertEquals("mi", restored.here.scrollUnit)
|
||||||
|
assertEquals(21, restored.here.scrollOffset)
|
||||||
|
|
||||||
|
// What the build on the phone saved: no scroll fields. It loads, at the top.
|
||||||
|
val older = "converter v1\norder\tlength\ncurrent\tlength\nmem\tlength\t180\tmi\tmi\t180\n"
|
||||||
|
val loaded = Converter.restore(older, units)!!
|
||||||
|
assertEquals("180", loaded.here.value)
|
||||||
|
assertNull(loaded.here.scrollUnit)
|
||||||
|
assertEquals(0, loaded.here.scrollOffset)
|
||||||
|
|
||||||
|
// An anchor the engine has since dropped from the category: the top, not a guess.
|
||||||
|
val dropped = "converter v1\norder\tlength\ncurrent\tlength\nmem\tlength\t1\tm\tm\t\tfurlong\t30\n"
|
||||||
|
assertNull(Converter.restore(dropped, units)!!.here.scrollUnit)
|
||||||
|
assertEquals(0, Converter.restore(dropped, units)!!.here.scrollOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
android/build.gradle.kts
Normal file
13
android/build.gradle.kts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
// Versions live here rather than in a catalogue: there is one module, and one place to
|
||||||
|
// bump is enough.
|
||||||
|
//
|
||||||
|
// NOTE: these versions have not been resolved on this machine - no JDK, Gradle or Android
|
||||||
|
// SDK is installed here, by choice (everything goes through mise when the toolchain
|
||||||
|
// lands). If Gradle rejects a version on first run, it is a bump rather than a redesign:
|
||||||
|
// AGP and the Kotlin Compose plugin have to agree with each other and with the Gradle
|
||||||
|
// version in `gradle/wrapper/gradle-wrapper.properties`.
|
||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.7.3" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
|
||||||
|
}
|
||||||
8
android/gradle.properties
Normal file
8
android/gradle.properties
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.caching=true
|
||||||
|
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
|
||||||
|
kotlin.code.style=official
|
||||||
17
android/settings.gradle.kts
Normal file
17
android/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "Tally"
|
||||||
|
include(":app")
|
||||||
155
build.zig
155
build.zig
|
|
@ -26,7 +26,7 @@ pub fn build(b: *std.Build) void {
|
||||||
});
|
});
|
||||||
b.installArtifact(engine_lib);
|
b.installArtifact(engine_lib);
|
||||||
|
|
||||||
// -- Engine shared library (for Android JNI) --
|
// -- Engine shared library (the C ABI, for Android JNI and any other FFI caller) --
|
||||||
const engine_shared = b.addLibrary(.{
|
const engine_shared = b.addLibrary(.{
|
||||||
.linkage = .dynamic,
|
.linkage = .dynamic,
|
||||||
.name = "tally",
|
.name = "tally",
|
||||||
|
|
@ -34,11 +34,9 @@ pub fn build(b: *std.Build) void {
|
||||||
.root_source_file = b.path("engine/src/c_api.zig"),
|
.root_source_file = b.path("engine/src/c_api.zig"),
|
||||||
.target = target,
|
.target = target,
|
||||||
.optimize = optimize,
|
.optimize = optimize,
|
||||||
.imports = &.{
|
|
||||||
.{ .name = "engine", .module = engine_mod },
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
engine_shared.installHeader(b.path("include/tally.h"), "tally.h");
|
||||||
b.installArtifact(engine_shared);
|
b.installArtifact(engine_shared);
|
||||||
|
|
||||||
// -- Main binary (CLI + TUI in one) --
|
// -- Main binary (CLI + TUI in one) --
|
||||||
|
|
@ -80,6 +78,51 @@ pub fn build(b: *std.Build) void {
|
||||||
const run_engine_tests = b.addRunArtifact(engine_tests);
|
const run_engine_tests = b.addRunArtifact(engine_tests);
|
||||||
const run_cli_tests = b.addRunArtifact(cli_tests);
|
const run_cli_tests = b.addRunArtifact(cli_tests);
|
||||||
|
|
||||||
|
// The C ABI gets its own root: `c_api.zig` imports the engine by path rather than by
|
||||||
|
// name (it is the root of the shared library, which has no import graph of its own),
|
||||||
|
// so the engine test target does not reach it.
|
||||||
|
const abi_tests = b.addTest(.{
|
||||||
|
// Only this file's tests: `c_api.zig` imports the engine by path, so an
|
||||||
|
// unfiltered run would execute the whole engine suite a second time.
|
||||||
|
.filters = &.{"c_api."},
|
||||||
|
.root_module = b.createModule(.{
|
||||||
|
.root_source_file = b.path("engine/src/c_api.zig"),
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const run_abi_tests = b.addRunArtifact(abi_tests);
|
||||||
|
|
||||||
|
// The JNI layer is Android-only code, but its tests are not: they run against a
|
||||||
|
// synthetic function table on the host, which is the only way to exercise the
|
||||||
|
// marshalling without a device.
|
||||||
|
const jni_tests = b.addTest(.{
|
||||||
|
.filters = &.{"jni."},
|
||||||
|
.root_module = b.createModule(.{
|
||||||
|
.root_source_file = b.path("engine/src/jni.zig"),
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const run_jni_tests = b.addRunArtifact(jni_tests);
|
||||||
|
|
||||||
|
// And the boundary is also tested from C, through the installed header, linked
|
||||||
|
// against the real library. That is the only way to catch a header that disagrees
|
||||||
|
// with the library: struct field order and symbol names are invisible from Zig.
|
||||||
|
const c_abi_mod = b.createModule(.{
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
.link_libc = true,
|
||||||
|
});
|
||||||
|
c_abi_mod.addCSourceFile(.{
|
||||||
|
.file = b.path("engine/test/c_abi_test.c"),
|
||||||
|
.flags = &.{ "-std=c99", "-Wall", "-Wextra", "-Werror" },
|
||||||
|
});
|
||||||
|
c_abi_mod.addIncludePath(b.path("include"));
|
||||||
|
c_abi_mod.linkLibrary(engine_shared);
|
||||||
|
const c_abi_test = b.addExecutable(.{ .name = "c-abi-test", .root_module = c_abi_mod });
|
||||||
|
const run_c_abi_test = b.addRunArtifact(c_abi_test);
|
||||||
|
|
||||||
// TUI tests need their own root: src/main.zig only reaches tui.zig from
|
// TUI tests need their own root: src/main.zig only reaches tui.zig from
|
||||||
// main(), which is never analyzed in test mode, so its tests would be
|
// main(), which is never analyzed in test mode, so its tests would be
|
||||||
// silently skipped if we relied on the CLI test target.
|
// silently skipped if we relied on the CLI test target.
|
||||||
|
|
@ -100,6 +143,9 @@ pub fn build(b: *std.Build) void {
|
||||||
test_step.dependOn(&run_engine_tests.step);
|
test_step.dependOn(&run_engine_tests.step);
|
||||||
test_step.dependOn(&run_cli_tests.step);
|
test_step.dependOn(&run_cli_tests.step);
|
||||||
test_step.dependOn(&run_tui_tests.step);
|
test_step.dependOn(&run_tui_tests.step);
|
||||||
|
test_step.dependOn(&run_abi_tests.step);
|
||||||
|
test_step.dependOn(&run_jni_tests.step);
|
||||||
|
test_step.dependOn(&run_c_abi_test.step);
|
||||||
|
|
||||||
// -- Coverage step (uses kcov, Linux x86_64/aarch64 only) --
|
// -- Coverage step (uses kcov, Linux x86_64/aarch64 only) --
|
||||||
//
|
//
|
||||||
|
|
@ -142,6 +188,107 @@ pub fn build(b: *std.Build) void {
|
||||||
// even though they are not compiled into this binary, so the two app reports
|
// even though they are not compiled into this binary, so the two app reports
|
||||||
// stay disjoint by construction rather than by accident.
|
// stay disjoint by construction rather than by accident.
|
||||||
_ = cov.addReport(tui_cov, "tally-tui", &.{ "src/tui.zig", "src/tui" });
|
_ = cov.addReport(tui_cov, "tally-tui", &.{ "src/tui.zig", "src/tui" });
|
||||||
|
|
||||||
|
// The C ABI reports separately. Its test binary contains all of `engine/src`,
|
||||||
|
// but the include path narrows the report to the one file that is not already
|
||||||
|
// accounted for by the engine report: `c_api.zig` is unreachable from
|
||||||
|
// `engine.zig`, so the engine's binary does not contain it and the two reports
|
||||||
|
// stay disjoint by file even though one include path nests inside the other.
|
||||||
|
const abi_cov = b.createModule(.{
|
||||||
|
.root_source_file = b.path("engine/src/c_api.zig"),
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
});
|
||||||
|
_ = cov.addReport(abi_cov, "tally-abi", &.{"engine/src/c_api.zig"});
|
||||||
|
|
||||||
|
// And the JNI layer, whose tests run against a synthetic function table. Android
|
||||||
|
// code measured on a desktop: everything except the four table indices, which
|
||||||
|
// only a JVM can confirm.
|
||||||
|
const jni_cov = b.createModule(.{
|
||||||
|
.root_source_file = b.path("engine/src/jni.zig"),
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
});
|
||||||
|
_ = cov.addReport(jni_cov, "tally-jni", &.{"engine/src/jni.zig"});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Android shared libraries --
|
||||||
|
//
|
||||||
|
// One `.so` per ABI, laid out the way Gradle's `jniLibs` expects, so packaging is a
|
||||||
|
// copy rather than a build step: Gradle never invokes Zig (design 1).
|
||||||
|
//
|
||||||
|
// The root here is `jni.zig` rather than `c_api.zig`: it pulls in the C ABI and adds
|
||||||
|
// the `Java_*` entry points, which are the only Android-specific code in the tree
|
||||||
|
// (design 6.3). The host library stays C-only.
|
||||||
|
//
|
||||||
|
// Always ReleaseSmall. A debug build of this library is twenty times the size and
|
||||||
|
// would be a strange thing to ship inside an APK; `zig build` without this step
|
||||||
|
// still produces a host library at whatever `-Doptimize` says.
|
||||||
|
{
|
||||||
|
const android_step = b.step("android", "Cross-compile libtally.so for Android ABIs");
|
||||||
|
const abis = [_]struct { triple: []const u8, dir: []const u8 }{
|
||||||
|
.{ .triple = "aarch64-linux-android", .dir = "arm64-v8a" },
|
||||||
|
.{ .triple = "x86_64-linux-android", .dir = "x86_64" },
|
||||||
|
.{ .triple = "arm-linux-androideabi", .dir = "armeabi-v7a" },
|
||||||
|
};
|
||||||
|
for (abis) |abi| {
|
||||||
|
const query = std.Target.Query.parse(.{ .arch_os_abi = abi.triple }) catch
|
||||||
|
@panic("bad Android target triple");
|
||||||
|
const lib = b.addLibrary(.{
|
||||||
|
.linkage = .dynamic,
|
||||||
|
.name = "tally",
|
||||||
|
.root_module = b.createModule(.{
|
||||||
|
.root_source_file = b.path("engine/src/jni.zig"),
|
||||||
|
.target = b.resolveTargetQuery(query),
|
||||||
|
.optimize = .ReleaseSmall,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
// Android 15 introduced 16KB memory pages, and Play requires shared
|
||||||
|
// libraries to be aligned for them. The default here is 4KB, which loads
|
||||||
|
// fine on a 4KB device and is rejected at submission.
|
||||||
|
lib.link_z_max_page_size = 16384;
|
||||||
|
const install = b.addInstallArtifact(lib, .{
|
||||||
|
.dest_dir = .{ .override = .{ .custom = b.fmt("android/{s}", .{abi.dir}) } },
|
||||||
|
});
|
||||||
|
android_step.dependOn(&install.step);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- The JNI layer against a real JVM --
|
||||||
|
//
|
||||||
|
// Its own step rather than part of `test`, because it needs a JDK and the rest of the
|
||||||
|
// suite needs nothing but Zig.
|
||||||
|
//
|
||||||
|
// This is the check that was supposed to require a device. JNI's function table is
|
||||||
|
// fixed by the specification rather than by the platform, so the indices hand-written
|
||||||
|
// in `jni.zig` resolve the same way on a desktop JVM as on Android: `System.load`
|
||||||
|
// runs `JNI_OnLoad`, which round-trips a string through two of them and fails loudly
|
||||||
|
// if they are not where it thinks.
|
||||||
|
{
|
||||||
|
const step = b.step("jvm-test", "Prove the JNI function table against a real JVM (needs a JDK)");
|
||||||
|
|
||||||
|
const jni_lib = b.addLibrary(.{
|
||||||
|
.linkage = .dynamic,
|
||||||
|
.name = "tally",
|
||||||
|
.root_module = b.createModule(.{
|
||||||
|
.root_source_file = b.path("engine/src/jni.zig"),
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const javac = b.addSystemCommand(&.{ "javac", "-d" });
|
||||||
|
const classes = javac.addOutputDirectoryArg("classes");
|
||||||
|
javac.addFileArg(b.path("engine/test/java/dev/lerch/tally/TallyEngine.java"));
|
||||||
|
|
||||||
|
const java = b.addSystemCommand(&.{"java"});
|
||||||
|
java.addPrefixedDirectoryArg("-Djava.library.path=", jni_lib.getEmittedBinDirectory());
|
||||||
|
java.addArg("-cp");
|
||||||
|
java.addDirectoryArg(classes);
|
||||||
|
java.addArg("dev.lerch.tally.TallyEngine");
|
||||||
|
java.expectStdOutEqual("jvm jni test: all checks passed\n");
|
||||||
|
// A failing check exits non-zero, which the run step treats as a build failure.
|
||||||
|
step.dependOn(&java.step);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Run step --
|
// -- Run step --
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue