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-out/
|
||||
|
||||
# Gradle and Android build artifacts
|
||||
android/.gradle/
|
||||
android/.kotlin/
|
||||
android/build/
|
||||
android/app/build/
|
||||
android/local.properties
|
||||
|
||||
# Temporary files
|
||||
.tmp/
|
||||
|
||||
|
|
|
|||
53
.mise.toml
53
.mise.toml
|
|
@ -3,5 +3,58 @@ zig = "0.16.0"
|
|||
zls = "0.16.0"
|
||||
"github:j178/prek" = "0.4.1"
|
||||
|
||||
# Android. Not installed by default on a machine that only builds the CLI and TUI: run
|
||||
# `mise install` in this directory to pull them when working on the app.
|
||||
#
|
||||
# `mise install` is necessary but not sufficient. It provides these four tools; the SDK
|
||||
# packages they need are downloaded by `sdkmanager` into the android-sdk install
|
||||
# directory, where mise does not track them - so they survive nothing and are reproduced
|
||||
# by the tasks below rather than by this table. No NDK: the native library is built by Zig
|
||||
# without one, and the JNI glue is hand-written (design 6.2, 6.4). See android/SETUP.md.
|
||||
java = "temurin-21"
|
||||
gradle = "8.14.5"
|
||||
android-sdk = "23.0"
|
||||
|
||||
[tools."github:DonIsaac/zlint"]
|
||||
version = "0.9.0"
|
||||
|
||||
# -- Android SDK packages --
|
||||
#
|
||||
# The versioned ones are pinned; `platform-tools` and `emulator` have no version in their
|
||||
# package id and always resolve to current, which is the one thing here that is not
|
||||
# reproducible. Both tasks are idempotent: an already-installed package is a no-op.
|
||||
|
||||
[tasks.android-sdk]
|
||||
description = "Install the SDK packages needed to build the app (~450MB)"
|
||||
run = 'sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"'
|
||||
|
||||
[tasks.android-emulator]
|
||||
description = "Install the emulator and a 16KB-page system image (~4.6GB)"
|
||||
run = 'sdkmanager "emulator" "system-images;android-35;google_apis_ps16k;x86_64"'
|
||||
|
||||
[tasks.android-avd]
|
||||
description = "Create the tally35 AVD in .tmp/avd - recreating it wipes installed apps"
|
||||
run = '''
|
||||
mkdir -p "$MISE_PROJECT_ROOT/.tmp/avd"
|
||||
ANDROID_AVD_HOME="$MISE_PROJECT_ROOT/.tmp/avd" avdmanager create avd \
|
||||
--name tally35 --device pixel_6 --force \
|
||||
--package "system-images;android-35;google_apis_ps16k;x86_64"
|
||||
'''
|
||||
|
||||
[tasks.android-install]
|
||||
description = "Build the native libs and APK, install on the attached device, launch it"
|
||||
# Rebuilds the libraries every time on purpose: Gradle never invokes Zig, so skipping
|
||||
# this step packages a stale libtally.so without complaint.
|
||||
run = '''
|
||||
set -e
|
||||
zig build android
|
||||
(cd "$MISE_PROJECT_ROOT/android" && gradle --no-daemon assembleDebug)
|
||||
adb wait-for-device
|
||||
until [ "$(adb shell getprop sys.boot_completed | tr -d '\r')" = "1" ]; do sleep 2; done
|
||||
adb install -r "$MISE_PROJECT_ROOT/android/app/build/outputs/apk/debug/app-debug.apk"
|
||||
adb shell am start -n dev.lerch.tally/.MainActivity
|
||||
'''
|
||||
|
||||
[tasks.android-run]
|
||||
description = "Boot the tally35 AVD - does not install the app (add -- -no-window for headless)"
|
||||
run = 'ANDROID_AVD_HOME="$MISE_PROJECT_ROOT/.tmp/avd" emulator -avd tally35 -no-audio -no-boot-anim -gpu swiftshader_indirect'
|
||||
|
|
|
|||
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);
|
||||
|
||||
// -- 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(.{
|
||||
.linkage = .dynamic,
|
||||
.name = "tally",
|
||||
|
|
@ -34,11 +34,9 @@ pub fn build(b: *std.Build) void {
|
|||
.root_source_file = b.path("engine/src/c_api.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.imports = &.{
|
||||
.{ .name = "engine", .module = engine_mod },
|
||||
},
|
||||
}),
|
||||
});
|
||||
engine_shared.installHeader(b.path("include/tally.h"), "tally.h");
|
||||
b.installArtifact(engine_shared);
|
||||
|
||||
// -- 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_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
|
||||
// main(), which is never analyzed in test mode, so its tests would be
|
||||
// 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_cli_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) --
|
||||
//
|
||||
|
|
@ -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
|
||||
// stay disjoint by construction rather than by accident.
|
||||
_ = 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 --
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue