initial android (ai generated)

This commit is contained in:
Emil Lerch 2026-10-03 12:28:47 -07:00
parent b86ba42811
commit a9b7e0fb1d
Signed by: lobo
GPG key ID: A7B62D657EF764F8
31 changed files with 3990 additions and 4 deletions

7
.gitignore vendored
View file

@ -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/

View file

@ -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
View 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
View 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.

View 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")
}

View file

@ -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.
}
}
}

View 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>

View 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"
}
}

View 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()
}
}

View 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)
}
}
}
}

View 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),
)
}

View 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
}
}

View 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)
}
}

View 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
},
)
}
}

View 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))
}
}
}
}
}

View 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,
)
}
}
}

View 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()
}
}

View 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,
)
},
)
}
}
}

View 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>

View 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>

View file

@ -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>

View file

@ -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>

View 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>

View 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>

View 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>

View 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"))
}
}

View 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
View 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
}

View 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

View file

@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Tally"
include(":app")

155
build.zig
View file

@ -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 --