8 Commits

Author SHA1 Message Date
ml ca85530b5a Add adaptive preprocessing escalation and single-receipt test scripts
build / build (push) Successful in 2m57s
Preprocessing now tries progressively heavier variants per receipt
(plain -> high-pass -> edge-enhance -> both), stopping as soon as a
cheap LLM-free arithmetic check on the OCR text looks plausible, so
the extra processing only applies to receipts that need it. A flat
version of the same heavier processing regressed the full batch by
helping hard receipts but hurting easy ones; the adaptive version
improves date-matching 37/52 -> 40/52 with no regression (see
testing/2026_10_04_gimp_highpass_nlfilter_experiment.md).

Also adds test_ocr.sh and test_single.sh for checking a single
receipt's OCR text or full pipeline result without a batch run.
2026-10-04 10:25:41 +02:00
ml 9bb1844cf4 Combine scan+receipts into one flow, add plausibility check,
build / build (push) Failing after 2m52s
photo viewing, and record drill-down

- Overview is now the single front page (list + "+" FAB to scan)
  instead of a separate near-empty Scan tab; export moved from
  Overview into the drawer
- Drop petrol_station/transaction_number from storage, the edit
  form, and CSV/ODS export - not useful for a personal fuel-cost log
  (destructive Room migration, version 1->2 already shipped earlier)
- Add a live plausibility check (total_price vs. price_per_litre *
  litres - discount) shown as a green/red badge on the review screen
- Add a shared Details/Photo review screen (ReceiptReviewScreen),
  with pinch-to-zoom on the photo (new Coil dependency), reused both
  right after a scan and when drilling into an already-saved receipt
  from the list (ReceiptDetailScreen, with Update/Delete)
- Fix receipt photos not surviving an app restart: camera/gallery
  captures now write to internal storage (filesDir) instead of the
  cache dir, which the OS can clear independent of the database; the
  gallery picker was also saving the Photo Picker's own short-lived
  content:// Uri instead of a Uri onto our persisted copy
- App log: add a Copy button and selectable text
- Fix the connection status bar overlapping the system gesture nav
  bar under edge-to-edge
- Settings: prefill an editable example server URL, add a "Test
  connection" button; both it and the connection status bar now
  surface the actual failure reason, not just pass/fail
2026-10-03 06:44:22 +02:00
matthias 68e0cd8967 Adding testing experiments
build / build (push) Failing after 2m59s
2026-10-03 06:34:38 +02:00
ml ad2ef1689e Improve app UX (server setup, status, gallery, log) and allow
build / build (push) Failing after 3m0s
manual CI publishes

- Settings: prefill an editable example server URL, add a "Test
  connection" button
- Front page now shows the receipts list (Overview) with a column
  header and an empty-state message
- Add a connection status bar (polls /health) shown on every screen
- Capture: add "Choose from gallery" alongside the camera
- Add an in-app "App log" screen; ApiClient/CaptureViewModel/
  OverviewViewModel/ConnectionStatusViewModel now log key events,
  including the actual connection-failure reason (not just pass/fail)
- .gitea/workflows/build.yml: publish to dl.ladkau.de on a manual
  "Run workflow" dispatch too, not just push
- README.md: document the CI secrets (REGISTRY_USER/PASSWORD,
  DL_SFTP_KEY) that must be configured in Gitea, separate from the
  local registry.env
2026-09-29 19:33:39 +02:00
ml d5f4381169 Add a custom Android CI/release build image and packaging scripts
build / build (push) Failing after 0s
- build-image/: Dockerfile bundling JDK 17, the Android SDK, and
  Node.js (needed by Gitea's JS-based actions) - the app has no
  native code, so no NDK
- build-image.sh/upload-image.sh: extended to also build/push this
  image alongside the existing OCR pipeline tools image
- run-image.sh: runs `make android` inside the image against a live
  checkout, for a CI-equivalent local build
- Makefile: `make android` builds and versions a release APK into
  dist/, from the current git tag
- .gitea/workflows/build.yml: now pulls the custom image and just
  runs `make android`, replacing the ad-hoc Node/SDK install steps
- README.md: describes the pipeline, server, app, and build tooling
- registry.env.example: fix REGISTRY_IMAGE to be fully-qualified
  (an unqualified name was silently resolving pushes to Docker Hub
  instead of the configured registry)
2026-09-29 17:48:02 +02:00
ml 516f5d3b7d Add HTTP API server, Android app, and CI build/publish pipeline
build / build (push) Failing after 14s
- docker-compose.yml + tooling-container/src/server.py: serve
  preprocess/OCR/extract over an async job API (POST /jobs, GET
  /jobs/{id}) instead of manual host-script runs per receipt
- android/: Kotlin/Compose app - capture a receipt photo, poll job
  progress, review/edit extracted fields, save locally (Room), export
  to CSV/ODS
- .gitea/workflows/build.yml: build the debug APK and publish it to
  dl.ladkau.de on push
- build-image.sh: also pull ollama/ollama so all pipeline images are
  ready after one script
- add project logo (also used for the app's launcher icon/splash)
2026-09-29 12:58:58 +02:00
matthias 7cea285ec3 Rename project from gastronomy to fuelish
Renamed all image tags, container/volume names, and doc references.
2026-09-28 19:47:05 +02:00
matthias 7678d28717 Add containerized OCR pipeline for fuel receipts
Self-hosted, verifiable pipeline for extracting structured data (station,
date/time, price per litre, litres, discount, total) from German fuel
receipt photos: grayscale + mild bilateral filter + contrast stretch,
Tesseract OCR, then LLM structuring via a small local model (Qwen 2.5,
served by Ollama). Each step is independently runnable and inspectable
through numbered scripts against a shared tooling container.

Ollama runs as its own long-lived container (0_run_ollama.sh), separate
from the stateless per-receipt pipeline steps, so the model stays loaded
in memory between runs. Includes a containerized ground-truth batch test
(test_batch.sh) that reproduces the full pipeline over the test receipts
and reports per-field accuracy.

Targets eventual deployment on a Raspberry Pi 4 k3s cluster.
2026-09-28 19:35:29 +02:00
79 changed files with 4216 additions and 58 deletions
+76
View File
@@ -0,0 +1,76 @@
name: build
# Builds the Android app (android/) debug APK on every push/PR, plus
# on-demand via the Gitea "Run workflow" button. Runs inside build-image
# (see ../../build-image/Dockerfile, built/pushed via ../../build-image.sh
# and ../../upload-image.sh), which bundles the JDK, Android SDK, and
# Node.js (needed by the JS-based actions below - see
# build-image/Dockerfile's own comment) so the runner itself doesn't need
# any of that installed. The Gradle build still needs network access at
# job runtime (Gradle/AGP/androidx dependency resolution).
on:
push:
pull_request:
workflow_dispatch:
jobs:
build:
# Runner defaults `run:` steps to `sh`, which doesn't understand
# `set -o pipefail` used below - force bash explicitly.
defaults:
run:
shell: bash
# Must match a label your act_runner is registered with.
runs-on: ubuntu-latest
container:
image: cr.ladkau.de/fuelish/android-builder:latest
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
steps:
- uses: actions/checkout@v4
with:
# `make android`'s version comes from `git describe --tags` -
# needs full history/tags, not actions/checkout's default
# shallow single-commit clone.
fetch-depth: 0
- name: Preflight
run: |
set -euo pipefail
command -v java >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: java not in PATH" >&2; exit 1; }
command -v make >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: make not in PATH" >&2; exit 1; }
[ -n "${ANDROID_HOME:-}" ] || { echo "PREFLIGHT FAIL: ANDROID_HOME not set" >&2; exit 1; }
- name: Build
run: make android
- name: Upload build artifact
# v4 uses the newer @actions/artifact backend, which this Gitea
# instance's artifact storage doesn't support (GHESNotSupportedError) — v3 works.
uses: actions/upload-artifact@v3
with:
name: fuelish-android
path: dist/fuelish-*.apk
- name: Publish to dl.ladkau.de
# Uploads the APK over SFTP instead of using actions/upload-artifact
# (whose zip wrapping can't be disabled). Runs on push and on a
# manual "Run workflow" dispatch (the on-demand way to publish an
# update without pushing a commit) - excludes pull_request so PR
# builds from unreviewed branches don't publish.
if: gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch'
run: |
set -euo pipefail
APK="$(ls dist/fuelish-*.apk)"
mkdir -p ~/.ssh
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
chmod 600 ~/.ssh/dl_sftp_key
sftp -i ~/.ssh/dl_sftp_key -P 2223 \
-o StrictHostKeyChecking=accept-new \
uploader@dl.ladkau.de <<EOF
-mkdir files/fuelish
put $APK files/fuelish/$(basename "$APK")
EOF
+22
View File
@@ -17,3 +17,25 @@
# instead of run through the container)
__pycache__/
*.pyc
# Build output (see the root Makefile's `android` target, run-image.sh)
/dist/
# Android app build output/local config and generated resources (launcher
# icon + splash art - see android/app/build.gradle.kts's
# generateAppIcons task and android/scripts/gen_app_icons.py's own
# doc comment).
/android/local.properties
/android/.gradle
/android/build
/android/app/build
/android/app/src/main/res/drawable-nodpi
/android/app/src/main/res/mipmap-mdpi
/android/app/src/main/res/mipmap-hdpi
/android/app/src/main/res/mipmap-xhdpi
/android/app/src/main/res/mipmap-xxhdpi
/android/app/src/main/res/mipmap-xxxhdpi
# Android Studio project files - per-developer, not shared
/android/.idea/
/android/gradle/gradle-daemon-jvm.properties
Regular → Executable
+2 -2
View File
@@ -24,8 +24,8 @@ while [ $# -gt 0 ]; do
esac
done
CONTAINER=gastronomy-ollama
VOLUME=gastronomy-ollama-data
CONTAINER=fuelish-ollama
VOLUME=fuelish-ollama-data
PORT=11434
if docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Combines steps 1+2 (preprocess + OCR) adaptively: tries preprocess.py's
# variants cheapest-first (0=baseline, same as 1_preprocess_image.sh's
# default, up to 3=most elaborate), scoring each variant's OCR text with
# tooling-container/src/plausibility.py and stopping at the first one
# that looks receipt-consistent - no LLM call involved, so this costs
# nothing extra over the single-variant path for the receipts the
# baseline already reads correctly. See tooling-container/src/adaptive.py
# and testing/2026_10_04_gimp_highpass_nlfilter_experiment.md for why.
#
# Writes <out>/variant<N>/{01_gray,...,0X_final}.png and its own ocr.txt
# for every variant tried (for inspection - which variant was kept is in
# the printed summary) and <out>/ocr.txt (the winning variant's OCR
# text, ready for ./3_extract_data.sh).
#
# Usage: ./12_adaptive_preprocess_ocr.sh -in <photo> -out <scratch-dir>
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
ROOT="$(pwd)"
fail() { echo "FAIL: $*" >&2; exit 1; }
IN_PATH=""
OUT_DIR=""
while [ $# -gt 0 ]; do
case "$1" in
-in) IN_PATH="${2:-}"; shift 2 ;;
-out) OUT_DIR="${2:-}"; shift 2 ;;
*) fail "unknown argument: $1" ;;
esac
done
[ -n "$IN_PATH" ] && [ -n "$OUT_DIR" ] \
|| fail "usage: $0 -in <photo> -out <scratch-dir>"
[ -f "$IN_PATH" ] || fail "input file not found: $IN_PATH"
command -v docker >/dev/null 2>&1 || fail "docker not found in PATH"
IMAGE="${REGISTRY_IMAGE:-fuelish/tools}"
if [ -f "$ROOT/registry.env" ]; then
# shellcheck disable=SC1091
source "$ROOT/registry.env"
IMAGE="${REGISTRY_IMAGE:-$IMAGE}"
fi
VERSION="$(<"$ROOT/tooling-container/VERSION")"
IMAGE="$IMAGE:$VERSION"
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|| fail "$IMAGE not found locally - run ./build-image.sh first"
IN_ABS="$(realpath "$IN_PATH")"
IN_DIR="$(dirname "$IN_ABS")"
IN_FILE="$(basename "$IN_ABS")"
mkdir -p "$OUT_DIR"
OUT_ABS="$(realpath "$OUT_DIR")"
GROUP_ADD_ARGS=(--group-add "$(stat -c %g "$IN_DIR")")
OUT_GID="$(stat -c %g "$OUT_ABS")"
[ "$OUT_GID" = "$(stat -c %g "$IN_DIR")" ] || GROUP_ADD_ARGS+=(--group-add "$OUT_GID")
echo "== Adaptively preprocessing + OCRing $IN_FILE =="
docker run --rm \
--user "$(id -u):$(id -g)" \
"${GROUP_ADD_ARGS[@]}" \
-v "$IN_DIR:/data/in:ro" \
-v "$OUT_ABS:/data/out" \
"$IMAGE" \
python3 /opt/app/src/adaptive.py -in "/data/in/$IN_FILE" -out /data/out
echo "== Done =="
echo "Next: ./3_extract_data.sh -in $OUT_ABS/ocr.txt -out $OUT_ABS"
Regular → Executable
+1 -1
View File
@@ -27,7 +27,7 @@ done
command -v docker >/dev/null 2>&1 || fail "docker not found in PATH"
IMAGE="${REGISTRY_IMAGE:-gastronomy/tools}"
IMAGE="${REGISTRY_IMAGE:-fuelish/tools}"
if [ -f "$ROOT/registry.env" ]; then
# shellcheck disable=SC1091
source "$ROOT/registry.env"
Regular → Executable
+1 -1
View File
@@ -26,7 +26,7 @@ done
command -v docker >/dev/null 2>&1 || fail "docker not found in PATH"
IMAGE="${REGISTRY_IMAGE:-gastronomy/tools}"
IMAGE="${REGISTRY_IMAGE:-fuelish/tools}"
if [ -f "$ROOT/registry.env" ]; then
# shellcheck disable=SC1091
source "$ROOT/registry.env"
Regular → Executable
+1 -1
View File
@@ -28,7 +28,7 @@ done
command -v docker >/dev/null 2>&1 || fail "docker not found in PATH"
IMAGE="${REGISTRY_IMAGE:-gastronomy/tools}"
IMAGE="${REGISTRY_IMAGE:-fuelish/tools}"
if [ -f "$ROOT/registry.env" ]; then
# shellcheck disable=SC1091
source "$ROOT/registry.env"
+124
View File
@@ -0,0 +1,124 @@
# fuelish
Extracts structured data from photos of German fuel/petrol receipts:
petrol station, transaction number, date, time, price per litre, litres,
discount, and total price.
The extraction pipeline is self-hosted (no cloud AI APIs) and is built to
eventually run on a Raspberry Pi 4 cluster under k3s. Everything runs in
containers; there is no host Python environment to set up.
## Pipeline
Three verifiable, independently-inspectable steps, each a thin host
script that runs the actual work inside the `fuelish/tools` container
(`server.py` runs the same functions in-process for the live app - see
`12_adaptive_preprocess_ocr.sh` below for why steps 1+2 are combined
there):
1. `1_preprocess_image.sh -in <photo> -out <dir>` - grayscale conversion,
a mild bilateral filter, and a percentile contrast stretch (variant 0
of `tooling-container/src/preprocess.py`'s 4 variants - see
`12_adaptive_preprocess_ocr.sh`). Writes every intermediate stage
(`01_gray.png`, `02_bilateral.png`, `03_final.png`) so each step can
be checked on its own.
2. `2_ocr_image.sh -in <dir>/03_final.png -out <dir>` - Tesseract OCR
(German), writes `ocr.txt`.
3. `3_extract_data.sh -in <dir>/ocr.txt -out <dir>` - sends the raw OCR
text to a small local LLM (`qwen2.5:3b`, served by Ollama) with a JSON
schema, writes `extracted.json`.
Preprocessing choice is evidence-based, not assumed: heavier processing
(strong denoising, CLAHE, naive unsharp masking, and a GIMP-derived
high-pass/edge-enhance combo - see
`testing/2026_10_04_gimp_highpass_nlfilter_experiment.md`) consistently
measured worse on the full batch *when applied unconditionally to every
receipt* - each one amplifies the receipt's dotted security-background
pattern into OCR noise, even though each visibly helps the specific hard
receipts it was designed for. That experiment's accepted fix - trying
heavier variants only on receipts the cheap pass already reads
implausibly - is `12_adaptive_preprocess_ocr.sh` below.
## Supporting scripts
- `12_adaptive_preprocess_ocr.sh -in <photo> -out <dir>` - combines steps
1+2 adaptively: tries `preprocess.py`'s 4 variants (0=baseline/same as
`1_preprocess_image.sh`'s default, up to 3=highpass+edge-enhance
combined) cheapest-first, scoring each variant's OCR text with
`tooling-container/src/plausibility.py`'s cheap regex-based arithmetic
check (no LLM call) and stopping at the first variant that looks
receipt-consistent. This is what `test_batch.sh`, `test_single.sh`, and
`server.py` actually use (not steps 1+2 directly) - see
`testing/2026_10_04_gimp_highpass_nlfilter_experiment.md` for why.
Writes `<dir>/variant<N>/` per variant tried plus `<dir>/ocr.txt` for
the winning one.
- `0_run_ollama.sh [-model <name>]` - starts Ollama as its own long-lived
Docker container (`fuelish-ollama`, model weights in the named
volume `fuelish-ollama-data`), pulling the model if not already
present. Deliberately separate from the tooling container: Ollama needs
to stay resident in memory with the model loaded, while the pipeline
steps are short-lived and stateless (one `docker run` per receipt).
`3_extract_data.sh` reaches it via `--network host` at
`http://127.0.0.1:11434` by default (override with `OLLAMA_URL`/
`OLLAMA_MODEL`).
- `build-image.sh` / `upload-image.sh` - build/push the
`tooling-container/` image, tagged from `tooling-container/VERSION`.
Image name defaults to `fuelish/tools`, overridable via
`registry.env` (copy from `registry.env.example`; gitignored).
- `test_batch.sh -in <test-data-dir>` - runs the full pipeline over every
`*.jpg` in a directory and compares results against that directory's
ground-truth spreadsheet (`*.ods`). For an input dir `<path>/<name>` it
creates sibling `<path>/<name>-scratch/` (intermediate step output per
receipt) and `<path>/<name>-result/` (final `extracted.json` per
receipt, plus `summary.txt`). Both are gitignored.
- `test_single.sh -in <photo.jpg>` - the same pipeline run and
ground-truth comparison as `test_batch.sh`, for one receipt instead of
a whole directory - useful for re-checking a single known failure
without re-running a full batch. Ground truth is optional: if a `*.ods`
is found next to the photo it compares against it, otherwise it just
prints the extracted JSON. Same sibling `-scratch`/`-result` layout as
`test_batch.sh`, scoped to the one receipt.
- `test_ocr.sh -in <photo.jpg>` - preprocessing + OCR only (no LLM
extraction, no ground truth), printing the raw OCR text to stdout.
Leaves no files behind (runs in a temp dir, cleaned up on exit) -
for quickly checking what Tesseract actually reads off a receipt, e.g.
while iterating on `tooling-container/src/preprocess.py`.
## Current state
Last full run against the 52-receipt ground-truth test set
(`testdata/2026_benzin_kosten/`, not checked in - see `.gitignore`),
with the adaptive preprocessing escalation described above:
- 40/52 receipts date-matched to ground truth (up from 37/52 with the
single-variant/baseline-only preprocessing)
- price_per_litre: 34/40 correct, litres: 24/40, discount: 34/40,
total_price: 24/40
Known remaining failure modes:
- OCR garbling on the compact "litres EUR/l price" line on some receipts
(the raw text sometimes lacks recoverable digits) even after trying
all 4 preprocessing variants - the adaptive escalation above
meaningfully improved this but didn't eliminate it
- Occasional year-digit misreads in the date causing a false date-match
failure
- Low-resolution source photos (well below the ~4000x3000 the good
results come from) OCR poorly regardless of preprocessing
The structuring prompt in `tooling-container/src/extract.py` uses
deliberately non-colliding example values. An earlier version used real
receipt values as worked examples, which caused the small model to
copy/anchor on those exact numbers instead of reading the actual OCR
text - a subtle failure mode worth remembering if the prompt is revised.
## Not yet built
The Android app (`android/`), the HTTP job API (`docker-compose.yml`/
`server.py`), and cross-checking total_price against
price_per_litre * litres - discount (`android/.../ReceiptPlausibility.kt`
on the app side, `tooling-container/src/plausibility.py` on the pipeline
side) all now exist - see `README.md` for how they fit together. Not yet
built: a web UI to query/filter/export outside the app, and auth/TLS on
the server (deliberately out of scope for now - see `server.py`'s
docstring).
+45
View File
@@ -0,0 +1,45 @@
# Builds the Android app (android/) and packages a versioned release APK.
# The OCR pipeline itself (tooling-container/, 0-3_*.sh, test_batch.sh) is
# container-only and has no host build step, so this Makefile only covers
# the Android app.
ANDROID_DIR := android
ANDROID_APK := $(ANDROID_DIR)/app/build/outputs/apk/debug/app-debug.apk
DIST_DIR := dist
.PHONY: all android clean
all: android
# Builds the Android app (`./gradlew assembleDebug` in android/, via its
# own Gradle/AGP toolchain) and copies the resulting debug APK into
# dist/, named with the current git tag, or `make android VERSION=1.2.3`
# to override. Untagged builds (including every CI build, unless a
# release tag is pushed) get a 0.0.0-dev+<sha> placeholder version.
# Ships the auto-generated debug keystore, not a release signing config -
# fine for sideloading; a real release signing setup is a separate,
# not-yet-needed step. Requires the Android SDK (ANDROID_HOME set) - see
# build-image/Dockerfile for the exact version this is built/tested
# against, or run this inside it via ./run-image.sh.
android:
cd $(ANDROID_DIR) && ./gradlew assembleDebug
@mkdir -p $(DIST_DIR)
@V="$(VERSION)"; \
if [ -z "$$V" ]; then \
if TAG=$$(git describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null); then \
V=$${TAG#v}; \
else \
V="0.0.0-dev+$$(git rev-parse --short HEAD)"; \
echo "WARNING: HEAD is not on a vX.Y.Z tag - building placeholder version $$V (push a tag to drive a real release version)" >&2; \
fi; \
fi; \
case "$$V" in \
[0-9]*.[0-9]*.[0-9]*) ;; \
*) echo "PREFLIGHT FAIL: VERSION '$$V' is not a semantic version (expected X.Y.Z, optionally with a -pre+meta suffix)" >&2; exit 1;; \
esac; \
APK_NAME="fuelish-$$V.apk"; \
cp $(ANDROID_APK) "$(DIST_DIR)/$$APK_NAME"; \
echo "Wrote $(DIST_DIR)/$$APK_NAME"
clean:
rm -rf $(DIST_DIR) $(ANDROID_DIR)/build $(ANDROID_DIR)/app/build
+168 -2
View File
@@ -1,3 +1,169 @@
# gastronomy
<p align="center">
<img src="logo.png" width="280" alt="fuelish logo">
</p>
Extracting and organising fuel data from fuel receipts.
# fuelish
Extracts structured data from photos of German fuel/petrol receipts —
petrol station, transaction number, date, time, price per litre, litres,
discount, and total price — and lets you review, correct, save, and
export it from an Android app.
The extraction pipeline is self-hosted (no cloud AI APIs): Tesseract OCR
plus a small local LLM (`qwen2.5:3b`, served by Ollama), all running in
containers. It's built to eventually run on a Raspberry Pi 4 cluster
under k3s; see [`AGENT.md`](AGENT.md) for the pipeline's architecture,
preprocessing rationale, and current extraction-accuracy notes.
## How it fits together
- **Pipeline** (`tooling-container/`): preprocess → OCR → LLM-structure a
receipt photo into JSON. Runs either as three independently-inspectable
host scripts (for development/testing) or as an HTTP job API (for the
app).
- **Server** (`docker-compose.yml`): the same pipeline, served over HTTP
(`POST /jobs`, `GET /jobs/{id}`) so the Android app can submit a photo
and poll real per-stage progress, backed by an Ollama container.
- **Android app** (`android/`): take a photo, watch it get processed,
correct any misread fields, save it locally, browse everything saved
so far, and export to CSV or `.ods` (matching the layout of the
ground-truth spreadsheets under `testdata/`).
- **CI** (`.gitea/workflows/build.yml`): builds the Android debug APK on
every push and publishes it to dl.ladkau.de, using a custom build image
(`build-image/`) so the runner needs nothing preinstalled.
## Repository layout
```
tooling-container/ Pipeline source (preprocess.py, ocr.py, extract.py,
server.py, compare_ground_truth.py) and the
Dockerfile/VERSION for the fuelish/tools image
both the host scripts and docker-compose.yml run.
0-3_*.sh Numbered host scripts driving one pipeline stage
each, for independent inspection during
development (0_run_ollama.sh starts Ollama).
test_batch.sh Runs the full pipeline over a directory of test
receipts and compares against its ground-truth
.ods - see AGENT.md's "Current state".
test_single.sh test_batch.sh for one receipt instead of a
directory - re-check a single known failure.
test_ocr.sh Preprocessing + OCR only, prints the raw OCR
text to stdout - no files left behind.
docker-compose.yml Runs the pipeline as a long-lived HTTP job API
(api + ollama services) for the Android app.
android/ The Android app: Kotlin + Jetpack Compose, Room
for local storage, OkHttp against the server API,
CSV/.ods export.
build-image/ Dockerfile for the Android CI/release build
environment (JDK + Android SDK, no NDK needed -
the app has no native code), used by CI and by
the build-image.sh/run-image.sh/upload-image.sh
scripts at the repo root (see "Building" below).
.gitea/workflows/ CI: builds the Android APK and publishes it to
dl.ladkau.de.
Makefile Root Makefile - packages a versioned release APK
(`make android`).
testdata/ Real receipt photos + ground-truth .ods files
(gitignored, not checked in - personal data).
```
See [`AGENT.md`](AGENT.md) for the pipeline's architecture, why the
current preprocessing approach was chosen over heavier alternatives, and
the latest batch-accuracy numbers and known failure modes.
## Building
**The pipeline image** (`tooling-container/`), used by the numbered host
scripts, `test_batch.sh`, and `docker-compose.yml`:
```bash
./build-image.sh # builds fuelish/tools and the Android build-image
# (below), and pulls ollama/ollama
```
**The server**, once the tools image is built:
```bash
docker compose up -d # starts ollama + api; api reachable at :8080
```
**The Android app** (`android/`) needs the Android SDK
(`ANDROID_HOME` set). Locally, without installing it yourself:
```bash
cp registry.env.example registry.env # fill in your registry credentials
./build-image.sh # builds the android-builder image (JDK + Android SDK)
./run-image.sh # runs `make android` inside it, against this
# checkout -> dist/fuelish-<version>.apk
```
With the SDK already installed, `make android` (or
`cd android && ./gradlew assembleDebug`) works directly on the host.
**CI** builds the same way — `.gitea/workflows/build.yml` runs
`make android` inside the `build-image/` image (pulled from the
registry, published via `./upload-image.sh` after `./build-image.sh`)
and publishes the APK to dl.ladkau.de on every push to the default
branch.
CI needs its own copy of credentials, separate from your local
`registry.env` (which only `build-image.sh`/`upload-image.sh`/
`run-image.sh` on your machine read - Gitea Actions never sees it). Add
these as **repo Settings → Actions → Secrets**:
- `REGISTRY_USER` / `REGISTRY_PASSWORD` - to pull the private
`build-image/` image (same values as `registry.env`)
- `DL_SFTP_KEY` - the private key for the SFTP publish step to
dl.ladkau.de
Without them, the workflow fails early (pulling the build image, or
publishing) rather than partway through the build.
## Running
**Inspect the pipeline stage by stage**, for development/debugging one
receipt at a time:
```bash
./0_run_ollama.sh # once, starts Ollama
./1_preprocess_image.sh -in photo.jpg -out scratch/
./2_ocr_image.sh -in scratch/03_final.png -out scratch/
./3_extract_data.sh -in scratch/ocr.txt -out scratch/ # -> extracted.json
```
**Batch-test against ground truth**:
```bash
./test_batch.sh -in testdata/<some-set> # needs a *.ods ground-truth
# file in that directory
```
**Re-check one receipt** (ground truth optional - compared if a `*.ods`
is found next to the photo):
```bash
./test_single.sh -in testdata/<some-set>/some-receipt.jpg
```
**Just see what Tesseract reads**, no LLM call, no files left behind:
```bash
./test_ocr.sh -in testdata/<some-set>/some-receipt.jpg
```
**Serve it for the app**:
```bash
docker compose up -d
```
Then, in the Android app's Settings tab, point it at
`http://<this-host>:8080` (both HTTP-only - see the app's
`network_security_config.xml` - and LAN-only by design, no auth/TLS).
Take a photo, watch the 4-stage progress (upload → preprocess → OCR →
extract), correct anything the model got wrong, and save. The Receipts
tab lists everything saved so far and exports to CSV or `.ods`.
## License
MIT — see [`LICENSE`](LICENSE).
+80
View File
@@ -0,0 +1,80 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
}
android {
namespace = "de.ladkau.fuelish"
compileSdk = 36
defaultConfig {
applicationId = "de.ladkau.fuelish"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "0.0.0-dev"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
}
// Compiles with an actual JDK 17 toolchain (auto-provisioned - see
// settings.gradle.kts's foojay-resolver plugin) rather than whatever JDK
// launched Gradle, so it stays in sync with android.compileOptions above
// regardless of which JDK's on PATH.
kotlin {
jvmToolchain(17)
}
// Regenerates app/src/main/res/{mipmap-*,drawable-nodpi}/* from ../../logo.png
// (see scripts/gen_app_icons.py) - gitignored generated output, so a fresh
// checkout needs it produced before any resource-merge/asset-packaging task
// runs.
val generateAppIcons = tasks.register<Exec>("generateAppIcons") {
workingDir = rootDir
commandLine("python3", "scripts/gen_app_icons.py")
}
tasks.named("preBuild") {
dependsOn(generateAppIcons)
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.core)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.okhttp)
implementation(libs.coil.compose)
debugImplementation(libs.androidx.ui.tooling)
}
+9
View File
@@ -0,0 +1,9 @@
# kotlinx.serialization needs its generated serializer classes kept.
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclasseswithmembers class de.ladkau.fuelish.data.** {
*** Companion;
}
-keepclasseswithmembers class de.ladkau.fuelish.data.** {
kotlinx.serialization.KSerializer serializer(...);
}
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:name=".FuelishApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.Fuelish.Splash">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Fuelish.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,5 @@
package de.ladkau.fuelish
import android.app.Application
class FuelishApplication : Application()
@@ -0,0 +1,22 @@
package de.ladkau.fuelish
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import de.ladkau.fuelish.ui.FuelishApp
import de.ladkau.fuelish.ui.theme.FuelishTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
FuelishTheme {
FuelishApp()
}
}
}
}
@@ -0,0 +1,86 @@
package de.ladkau.fuelish.data
import de.ladkau.fuelish.log.AppLog
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
/** Talks to tooling-container/src/server.py's endpoints. Every call
* takes the server's base URL explicitly (from [ConfigRepository]) rather
* than storing it, since the user can change it from Settings mid-session. */
class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
@OptIn(ExperimentalSerializationApi::class)
private val json = Json {
ignoreUnknownKeys = true
namingStrategy = JsonNamingStrategy.SnakeCase
}
// Short timeouts for /health - it's polled every few seconds by the
// connection status bar and shouldn't hang waiting on a dead server.
private val healthClient = client.newBuilder()
.connectTimeout(3, TimeUnit.SECONDS)
.readTimeout(3, TimeUnit.SECONDS)
.build()
suspend fun submitImage(baseUrl: String, imageFile: File): String = withContext(Dispatchers.IO) {
try {
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("image", imageFile.name, imageFile.asRequestBody("image/jpeg".toMediaType()))
.build()
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs").post(body).build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("upload failed: HTTP ${response.code}")
val text = response.body?.string().orEmpty()
json.decodeFromString(CreateJobResponse.serializer(), text).jobId
}.also { jobId -> AppLog.log("Uploaded ${imageFile.name} to $baseUrl - job $jobId created") }
} catch (e: IOException) {
AppLog.log("Upload to $baseUrl failed: ${e.message}")
throw e
}
}
suspend fun getJobStatus(baseUrl: String, jobId: String): JobStatusResponse = withContext(Dispatchers.IO) {
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs/$jobId").get().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("status check failed: HTTP ${response.code}")
val text = response.body?.string().orEmpty()
json.decodeFromString(JobStatusResponse.serializer(), text)
}
}
/** GET /health. Used by the settings "Test connection" button and the
* connection status bar - both surface [HealthCheckResult.Failed]'s
* message so a bad URL/unreachable server is diagnosable from the
* phone's App log screen alone, without needing adb logcat. */
suspend fun checkHealth(baseUrl: String): HealthCheckResult = withContext(Dispatchers.IO) {
if (baseUrl.isBlank()) return@withContext HealthCheckResult.Failed("no server URL configured")
try {
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/health").get().build()
healthClient.newCall(request).execute().use { response ->
if (response.isSuccessful) HealthCheckResult.Ok else HealthCheckResult.Failed("HTTP ${response.code}")
}
} catch (e: IOException) {
// e.message is usually the genuinely useful part (e.g. "Failed
// to connect to /10.0.0.21:8000" for a wrong port, "Unable to
// resolve host" for a bad address) - the exception class name
// alone (e.g. ConnectException) isn't specific enough on its own.
HealthCheckResult.Failed("${e.javaClass.simpleName}: ${e.message}")
}
}
}
sealed interface HealthCheckResult {
data object Ok : HealthCheckResult
data class Failed(val message: String) : HealthCheckResult
}
@@ -0,0 +1,10 @@
package de.ladkau.fuelish.data
/**
* Everything the app needs that isn't a saved receipt: just the fuelish
* server's base URL (e.g. "http://192.168.1.10:8080") right now.
* Persisted via Preferences DataStore, see [ConfigRepository].
*/
data class AppConfig(
val serverUrl: String = "",
)
@@ -0,0 +1,29 @@
package de.ladkau.fuelish.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.configDataStore: DataStore<Preferences> by preferencesDataStore(name = "fuelish_config")
/** Preferences DataStore-backed persistence for [AppConfig]. */
class ConfigRepository(private val context: Context) {
private object Keys {
val SERVER_URL = stringPreferencesKey("server_url")
}
val config: Flow<AppConfig> = context.configDataStore.data.map { prefs ->
AppConfig(serverUrl = prefs[Keys.SERVER_URL] ?: "")
}
suspend fun save(config: AppConfig) {
context.configDataStore.edit { prefs ->
prefs[Keys.SERVER_URL] = config.serverUrl
}
}
}
@@ -0,0 +1,33 @@
package de.ladkau.fuelish.data
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
// version 2: dropped Receipt.petrolStation/transactionNumber. No migration
// path - fallbackToDestructiveMigration() wipes any locally-saved receipts
// on upgrade. Acceptable during active development (personal, single-user
// app, no real deployment yet); revisit with a real migration before that
// changes.
@Database(entities = [Receipt::class], version = 2, exportSchema = false)
abstract class FuelishDatabase : RoomDatabase() {
abstract fun receiptDao(): ReceiptDao
companion object {
@Volatile
private var instance: FuelishDatabase? = null
fun get(context: Context): FuelishDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
FuelishDatabase::class.java,
"fuelish.db",
)
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
.also { instance = it }
}
}
}
@@ -0,0 +1,38 @@
package de.ladkau.fuelish.data
import kotlinx.serialization.Serializable
/** Mirrors tooling-container/src/server.py's POST /jobs response. */
@Serializable
data class CreateJobResponse(val jobId: String)
/** Mirrors tooling-container/src/server.py's GET /jobs/{id} response. */
@Serializable
data class JobStatusResponse(
val status: String,
val result: ExtractedReceiptDto? = null,
val error: String? = null,
) {
companion object {
const val STATUS_QUEUED = "queued"
const val STATUS_PREPROCESSING = "preprocessing"
const val STATUS_OCR = "ocr"
const val STATUS_EXTRACTING = "extracting"
const val STATUS_DONE = "done"
const val STATUS_ERROR = "error"
}
}
/** Mirrors the fields of tooling-container/src/extract.py's SCHEMA this
* app actually keeps - petrol_station/transaction_number come back too
* but are ignored (ignoreUnknownKeys=true in ApiClient's Json), since
* they're not useful for a personal fuel-cost log. */
@Serializable
data class ExtractedReceiptDto(
val date: String? = null,
val time: String? = null,
val pricePerLitre: Double? = null,
val litres: Double? = null,
val discount: Double? = null,
val totalPrice: Double? = null,
)
@@ -0,0 +1,29 @@
package de.ladkau.fuelish.data
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* A confirmed receipt, as the user saved it after reviewing the server's
* extraction. This app is the system of record - the server is
* stateless processing only (see docker-compose.yml / server.py).
*
* Deliberately doesn't keep petrol_station/transaction_number even though
* the server extracts them (see ExtractedReceiptDto) - not useful for a
* personal fuel-cost log, so there's no reason to store or show them.
*/
@Entity(tableName = "receipts")
data class Receipt(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
/** DD.MM.YYYY, matching testdata/.../benzin_kosten.ods's ground-truth format. */
val date: String?,
/** HH:MM:SS. */
val time: String?,
val pricePerLitre: Double?,
val litres: Double?,
val discount: Double?,
val totalPrice: Double?,
/** content:// or file:// URI of the captured photo, kept for reference - null if unavailable. */
val imageUri: String?,
val createdAtEpochMillis: Long,
)
@@ -0,0 +1,26 @@
package de.ladkau.fuelish.data
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface ReceiptDao {
@Query("SELECT * FROM receipts ORDER BY date DESC, createdAtEpochMillis DESC")
fun observeAll(): Flow<List<Receipt>>
@Query("SELECT * FROM receipts WHERE id = :id")
suspend fun getById(id: Long): Receipt?
@Insert
suspend fun insert(receipt: Receipt): Long
@Update
suspend fun update(receipt: Receipt)
@Delete
suspend fun delete(receipt: Receipt)
}
@@ -0,0 +1,21 @@
package de.ladkau.fuelish.data
import kotlin.math.abs
/** Same tolerance tooling-container/src/compare_ground_truth.py's own
* close() uses for the equivalent ground-truth check - keeps "is this
* receipt's math consistent" judged the same way on both ends. */
private const val TOLERANCE_EUR = 0.05
enum class Plausibility { OK, DUBIOUS, UNKNOWN }
/** Cross-checks total_price against price_per_litre * litres - discount,
* the same arithmetic identity a real receipt must satisfy - a quick
* sanity check on whether a scan (or a hand-edit of one) is internally
* consistent. UNKNOWN (not DUBIOUS) when there isn't enough data to judge,
* e.g. mid-edit with a field temporarily blank. */
fun checkPlausibility(pricePerLitre: Double?, litres: Double?, discount: Double?, totalPrice: Double?): Plausibility {
if (pricePerLitre == null || litres == null || totalPrice == null) return Plausibility.UNKNOWN
val expected = pricePerLitre * litres - (discount ?: 0.0)
return if (abs(expected - totalPrice) <= TOLERANCE_EUR) Plausibility.OK else Plausibility.DUBIOUS
}
@@ -0,0 +1,27 @@
package de.ladkau.fuelish.data
import android.content.Context
import java.io.File
import kotlinx.coroutines.flow.Flow
/** Wraps the local Room store (the system of record for confirmed
* receipts) and the [ApiClient] (stateless per-receipt processing) behind
* one entry point for the ui layer. */
class ReceiptRepository(context: Context) {
private val dao = FuelishDatabase.get(context).receiptDao()
private val api = ApiClient()
val receipts: Flow<List<Receipt>> = dao.observeAll()
suspend fun getById(id: Long): Receipt? = dao.getById(id)
suspend fun submitImage(baseUrl: String, imageFile: File): String = api.submitImage(baseUrl, imageFile)
suspend fun pollJob(baseUrl: String, jobId: String): JobStatusResponse = api.getJobStatus(baseUrl, jobId)
suspend fun save(receipt: Receipt): Long = dao.insert(receipt)
suspend fun update(receipt: Receipt) = dao.update(receipt)
suspend fun delete(receipt: Receipt) = dao.delete(receipt)
}
@@ -0,0 +1,28 @@
package de.ladkau.fuelish.export
import de.ladkau.fuelish.data.Receipt
import java.io.OutputStream
/** ";"-delimited, not ",": our cells use a German decimal comma (see
* [toExportRow]), which would otherwise collide with a comma delimiter -
* ";" is also what German-locale Excel/LibreOffice expect by default. */
private const val DELIMITER = ";"
private val UTF8_BOM = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte())
fun exportCsv(receipts: List<Receipt>, out: OutputStream) {
out.write(UTF8_BOM)
out.write(csvLine(EXPORT_HEADERS).toByteArray(Charsets.UTF_8))
for (receipt in receipts) {
out.write(csvLine(receipt.toExportRow()).toByteArray(Charsets.UTF_8))
}
}
private fun csvLine(fields: List<String>): String =
fields.joinToString(DELIMITER) { escapeCsvField(it) } + "\r\n"
private fun escapeCsvField(field: String): String =
if (field.contains(DELIMITER) || field.contains('"') || field.contains('\n')) {
"\"" + field.replace("\"", "\"\"") + "\""
} else {
field
}
@@ -0,0 +1,92 @@
package de.ladkau.fuelish.export
import de.ladkau.fuelish.data.Receipt
import java.io.OutputStream
import java.util.zip.CRC32
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
private const val MIMETYPE = "application/vnd.oasis.opendocument.spreadsheet"
/**
* A minimal hand-rolled ODF spreadsheet writer - no dependency needed,
* since an .ods is just a zip of a few fixed XML files. Writes only what
* testdata/.../benzin_kosten.ods's own reader (compare_ground_truth.py,
* via odfpy) needs: one table, string-typed cells (matching how that
* ground-truth sheet itself stores formatted values - see
* [toExportRow]'s doc comment), no styles/formulas.
*/
fun exportOds(receipts: List<Receipt>, out: OutputStream) {
ZipOutputStream(out).use { zip ->
// Must be the first entry, stored uncompressed with no extra
// fields, so ODF-aware tools can sniff the file type without
// inflating - the format spec requires this.
val mimetypeBytes = MIMETYPE.toByteArray(Charsets.US_ASCII)
val mimetypeEntry = ZipEntry("mimetype").apply {
method = ZipEntry.STORED
size = mimetypeBytes.size.toLong()
compressedSize = mimetypeBytes.size.toLong()
crc = CRC32().apply { update(mimetypeBytes) }.value
}
zip.putNextEntry(mimetypeEntry)
zip.write(mimetypeBytes)
zip.closeEntry()
zip.putNextEntry(ZipEntry("META-INF/manifest.xml"))
zip.write(manifestXml().toByteArray(Charsets.UTF_8))
zip.closeEntry()
zip.putNextEntry(ZipEntry("content.xml"))
zip.write(contentXml(receipts).toByteArray(Charsets.UTF_8))
zip.closeEntry()
}
}
private fun manifestXml(): String = """
<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">
<manifest:file-entry manifest:full-path="/" manifest:version="1.2" manifest:media-type="$MIMETYPE"/>
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
</manifest:manifest>
""".trimIndent()
private fun contentXml(receipts: List<Receipt>): String {
val rows = buildString {
append(tableRow(EXPORT_HEADERS))
for (receipt in receipts) {
append(tableRow(receipt.toExportRow()))
}
}
return """
<?xml version="1.0" encoding="UTF-8"?>
<office:document-content
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
office:version="1.2">
<office:body>
<office:spreadsheet>
<table:table table:name="Receipts">
$rows
</table:table>
</office:spreadsheet>
</office:body>
</office:document-content>
""".trimIndent()
}
private fun tableRow(cells: List<String>): String = buildString {
append("<table:table-row>")
for (cell in cells) {
append("<table:table-cell office:value-type=\"string\"><text:p>")
append(escapeXml(cell))
append("</text:p></table:table-cell>")
}
append("</table:table-row>")
}
private fun escapeXml(text: String): String = text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
@@ -0,0 +1,22 @@
package de.ladkau.fuelish.export
import de.ladkau.fuelish.data.Receipt
import java.util.Locale
/**
* Mirrors testdata/.../benzin_kosten.ods's layout exactly (date, time,
* price_per_litre, litres, discount, total_price - see
* tooling-container/src/compare_ground_truth.py's column order and its
* German-locale decimal-comma / "€"-suffix parsing, which this
* reproduces on the way out).
*/
val EXPORT_HEADERS = listOf("Datum", "Uhrzeit", "Preis/Liter", "Liter", "Rabatt", "Gesamtpreis")
fun Receipt.toExportRow(): List<String> = listOf(
date.orEmpty(),
time.orEmpty(),
pricePerLitre?.let { String.format(Locale.GERMANY, "%.3f", it) }.orEmpty(),
litres?.let { String.format(Locale.GERMANY, "%.2f", it) }.orEmpty(),
discount?.let { String.format(Locale.GERMANY, "%.2f €", it) }.orEmpty(),
totalPrice?.let { String.format(Locale.GERMANY, "%.2f €", it) }.orEmpty(),
)
@@ -0,0 +1,30 @@
package de.ladkau.fuelish.log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* A small in-memory, in-process log the app writes to at points a user
* would actually want to inspect when something goes wrong (server
* requests, connectivity changes, saves) - surfaced via the drawer's
* "App log" entry (ui/log/LogScreen.kt). Not persisted - it's a
* this-session diagnostic aid, not an audit trail.
*/
object AppLog {
data class Entry(val timestampMillis: Long, val message: String)
private const val MAX_ENTRIES = 500
private val _entries = MutableStateFlow<List<Entry>>(emptyList())
val entries: StateFlow<List<Entry>> = _entries.asStateFlow()
fun log(message: String) {
_entries.update { (it + Entry(System.currentTimeMillis(), message)).takeLast(MAX_ENTRIES) }
}
fun clear() {
_entries.value = emptyList()
}
}
@@ -0,0 +1,65 @@
package de.ladkau.fuelish.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.ladkau.fuelish.R
@Composable
fun ConnectionStatusBar(viewModel: ConnectionStatusViewModel = viewModel()) {
val status by viewModel.status.collectAsState()
val serverUrl by viewModel.serverUrl.collectAsState()
val color = when (status) {
ConnectionStatus.CONNECTED -> MaterialTheme.colorScheme.primary
ConnectionStatus.DISCONNECTED -> MaterialTheme.colorScheme.error
ConnectionStatus.NOT_CONFIGURED, ConnectionStatus.CHECKING -> MaterialTheme.colorScheme.outline
}
val text = when (status) {
ConnectionStatus.CONNECTED -> stringResource(R.string.status_connected, serverUrl)
ConnectionStatus.DISCONNECTED -> stringResource(R.string.status_disconnected, serverUrl)
ConnectionStatus.NOT_CONFIGURED -> stringResource(R.string.status_not_configured)
ConnectionStatus.CHECKING -> stringResource(R.string.status_checking)
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
// Edge-to-edge (MainActivity.enableEdgeToEdge()) means this bar
// would otherwise draw under the system gesture nav bar at the
// bottom of the screen, overlapping its swipe area - push the
// content above it while the background still extends full-bleed.
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(color),
)
Text(text = text, style = MaterialTheme.typography.bodySmall)
}
}
@@ -0,0 +1,69 @@
package de.ladkau.fuelish.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import de.ladkau.fuelish.data.ApiClient
import de.ladkau.fuelish.data.ConfigRepository
import de.ladkau.fuelish.data.HealthCheckResult
import de.ladkau.fuelish.log.AppLog
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
private const val POLL_INTERVAL_MILLIS = 10_000L
enum class ConnectionStatus { CHECKING, CONNECTED, DISCONNECTED, NOT_CONFIGURED }
/** Scoped to the Activity (created once at [de.ladkau.fuelish.ui.FuelishApp]'s
* top level, not per-screen), so the status bar keeps polling regardless of
* which drawer destination is showing. */
class ConnectionStatusViewModel(application: Application) : AndroidViewModel(application) {
private val configRepository = ConfigRepository(application)
private val api = ApiClient()
private val _status = MutableStateFlow(ConnectionStatus.CHECKING)
val status: StateFlow<ConnectionStatus> = _status.asStateFlow()
private val _serverUrl = MutableStateFlow("")
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
init {
viewModelScope.launch {
// collectLatest: a settings change (new server URL) cancels
// whatever poll loop is in flight and restarts against the
// new URL, rather than continuing to poll the old one.
configRepository.config.collectLatest { config ->
_serverUrl.value = config.serverUrl
if (config.serverUrl.isBlank()) {
_status.value = ConnectionStatus.NOT_CONFIGURED
} else {
pollLoop(config.serverUrl)
}
}
}
}
private suspend fun pollLoop(baseUrl: String) {
var lastStable: ConnectionStatus? = null
while (true) {
_status.value = ConnectionStatus.CHECKING
val result = api.checkHealth(baseUrl)
val newStatus = if (result is HealthCheckResult.Ok) ConnectionStatus.CONNECTED else ConnectionStatus.DISCONNECTED
if (newStatus != lastStable) {
AppLog.log(
when (result) {
is HealthCheckResult.Ok -> "Connected to $baseUrl"
is HealthCheckResult.Failed -> "Cannot reach server at $baseUrl: ${result.message}"
},
)
lastStable = newStatus
}
_status.value = newStatus
delay(POLL_INTERVAL_MILLIS)
}
}
}
@@ -0,0 +1,44 @@
package de.ladkau.fuelish.ui
import android.app.Application
import android.net.Uri
import androidx.core.content.FileProvider
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import de.ladkau.fuelish.data.Receipt
import de.ladkau.fuelish.data.ReceiptRepository
import de.ladkau.fuelish.export.exportCsv as writeCsvRows
import de.ladkau.fuelish.export.exportOds as writeOdsRows
import de.ladkau.fuelish.log.AppLog
import java.io.File
import java.io.OutputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** Scoped at the app level (de.ladkau.fuelish.ui.FuelishApp), not per-screen,
* since the export actions live in the drawer and should work regardless
* of which destination is currently showing. */
class ExportViewModel(application: Application) : AndroidViewModel(application) {
private val repository = ReceiptRepository(application)
fun exportCsv(onReady: (Uri) -> Unit) = export("fuelish-receipts.csv", ::writeCsvRows, onReady)
fun exportOds(onReady: (Uri) -> Unit) = export("fuelish-receipts.ods", ::writeOdsRows, onReady)
private fun export(fileName: String, write: (List<Receipt>, OutputStream) -> Unit, onReady: (Uri) -> Unit) {
val app = getApplication<Application>()
viewModelScope.launch {
val receipts = repository.receipts.first()
val uri = withContext(Dispatchers.IO) {
val dir = File(app.cacheDir, "exports").apply { mkdirs() }
val file = File(dir, fileName)
file.outputStream().use { write(receipts, it) }
FileProvider.getUriForFile(app, "${app.packageName}.fileprovider", file)
}
AppLog.log("Exported ${receipts.size} receipt(s) to $fileName")
onReady(uri)
}
}
}
@@ -0,0 +1,189 @@
package de.ladkau.fuelish.ui
import android.content.Intent
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import de.ladkau.fuelish.R
import de.ladkau.fuelish.ui.capture.CaptureScreen
import de.ladkau.fuelish.ui.log.LogScreen
import de.ladkau.fuelish.ui.overview.OverviewScreen
import de.ladkau.fuelish.ui.receipt.ReceiptDetailScreen
import de.ladkau.fuelish.ui.settings.ConfigScreen
import kotlinx.coroutines.launch
private object Destinations {
const val OVERVIEW = "overview"
const val CAPTURE = "capture"
const val SETTINGS = "settings"
const val LOG = "log"
const val RECEIPT_DETAIL_ARG = "receiptId"
const val RECEIPT_DETAIL = "receipt_detail/{$RECEIPT_DETAIL_ARG}"
fun receiptDetail(id: Long) = "receipt_detail/$id"
// Reached via the drawer, so the top bar shows the hamburger menu.
// Anything else (Capture, receipt detail) is reached by pushing on top
// of one of these, so it gets a back arrow instead - see FuelishApp's
// navigationIcon below.
val DRAWER_DESTINATIONS = setOf(OVERVIEW, SETTINGS, LOG)
}
/**
* Burger menu = ModalNavigationDrawer, leading to Receipts/Settings/App
* log plus the two export actions - Receipts is the front page (start
* destination) since that's what most opens of the app want to see, and
* scanning a new receipt is reached from its own "+" FAB rather than a
* separate always-visible tab (a bare scan screen with nothing else on it
* was pretty empty on its own).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FuelishApp() {
val navController = rememberNavController()
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
val context = LocalContext.current
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
val exportViewModel: ExportViewModel = viewModel()
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet {
NavigationDrawerItem(
label = { Text(stringResource(R.string.nav_overview)) },
selected = currentRoute == Destinations.OVERVIEW,
onClick = { navigateTo(navController, Destinations.OVERVIEW); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp),
)
NavigationDrawerItem(
label = { Text(stringResource(R.string.nav_settings)) },
selected = currentRoute == Destinations.SETTINGS,
onClick = { navigateTo(navController, Destinations.SETTINGS); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp),
)
NavigationDrawerItem(
label = { Text(stringResource(R.string.nav_log)) },
selected = currentRoute == Destinations.LOG,
onClick = { navigateTo(navController, Destinations.LOG); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp),
)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
NavigationDrawerItem(
label = { Text(stringResource(R.string.overview_export_csv)) },
selected = false,
onClick = {
scope.launch { drawerState.close() }
exportViewModel.exportCsv { uri -> shareFile(context, uri, "text/csv") }
},
modifier = Modifier.padding(horizontal = 12.dp),
)
NavigationDrawerItem(
label = { Text(stringResource(R.string.overview_export_ods)) },
selected = false,
onClick = {
scope.launch { drawerState.close() }
exportViewModel.exportOds { uri -> shareFile(context, uri, "application/vnd.oasis.opendocument.spreadsheet") }
},
modifier = Modifier.padding(horizontal = 12.dp),
)
}
},
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.app_name)) },
navigationIcon = {
if (currentRoute in Destinations.DRAWER_DESTINATIONS) {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.nav_settings))
}
} else {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.nav_back))
}
}
},
)
},
floatingActionButton = {
if (currentRoute == Destinations.OVERVIEW) {
FloatingActionButton(onClick = { navController.navigate(Destinations.CAPTURE) }) {
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.nav_capture))
}
}
},
bottomBar = { ConnectionStatusBar() },
) { innerPadding ->
NavHost(
navController = navController,
startDestination = Destinations.OVERVIEW,
modifier = Modifier.padding(innerPadding),
) {
composable(Destinations.OVERVIEW) {
OverviewScreen(onReceiptClick = { id -> navController.navigate(Destinations.receiptDetail(id)) })
}
composable(Destinations.CAPTURE) {
CaptureScreen(onSaved = { navController.popBackStack() })
}
composable(Destinations.SETTINGS) { ConfigScreen() }
composable(Destinations.LOG) { LogScreen() }
composable(
route = Destinations.RECEIPT_DETAIL,
arguments = listOf(navArgument(Destinations.RECEIPT_DETAIL_ARG) { type = NavType.LongType }),
) { backStackEntry ->
val id = backStackEntry.arguments?.getLong(Destinations.RECEIPT_DETAIL_ARG) ?: return@composable
ReceiptDetailScreen(receiptId = id, onDeleted = { navController.popBackStack() })
}
}
}
}
}
private fun navigateTo(navController: NavHostController, route: String) {
navController.navigate(route) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
private fun shareFile(context: android.content.Context, uri: android.net.Uri, mimeType: String) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = mimeType
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, null))
}
@@ -0,0 +1,160 @@
package de.ladkau.fuelish.ui.capture
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import de.ladkau.fuelish.R
import de.ladkau.fuelish.data.JobStatusResponse
import de.ladkau.fuelish.ui.receipt.ReceiptReviewScreen
import java.io.File
@Composable
fun CaptureScreen(onSaved: () -> Unit, viewModel: CaptureViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
var pendingFile by remember { mutableStateOf<File?>(null) }
var pendingUri by remember { mutableStateOf<Uri?>(null) }
val takePictureLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
val file = pendingFile
val uri = pendingUri
if (success && file != null && uri != null) {
viewModel.submit(file, uri)
}
}
fun launchCamera() {
val dir = File(context.filesDir, "receipts").apply { mkdirs() }
val file = File(dir, "capture-${System.currentTimeMillis()}.jpg")
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
pendingFile = file
pendingUri = uri
takePictureLauncher.launch(uri)
}
val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) launchCamera()
}
val pickImageLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { pickedUri ->
if (pickedUri != null) {
val file = copyPickedImageToCache(context, pickedUri)
if (file != null) {
// Must be a Uri onto our own persisted copy, NOT pickedUri
// itself - the system Photo Picker's content://media/picker/...
// Uri is a short-lived grant that stops resolving once this
// session ends (e.g. after the app is closed and reopened),
// regardless of whether the underlying bytes were copied
// somewhere durable.
val persistedUri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
viewModel.submit(file, persistedUri)
}
}
}
when (val state = uiState) {
is CaptureUiState.Idle -> Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = {
val hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
if (hasPermission) launchCamera() else cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
},
) {
Text(stringResource(R.string.capture_take_photo))
}
Button(
onClick = {
pickImageLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
},
) {
Text(stringResource(R.string.capture_choose_gallery))
}
}
}
is CaptureUiState.Uploading -> Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
ProgressStage(stringResource(R.string.capture_stage_uploading), 0.2f)
}
is CaptureUiState.Processing -> Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
val (labelRes, progress) = when (state.stage) {
JobStatusResponse.STATUS_PREPROCESSING -> R.string.capture_stage_preprocessing to 0.45f
JobStatusResponse.STATUS_OCR -> R.string.capture_stage_ocr to 0.7f
JobStatusResponse.STATUS_EXTRACTING -> R.string.capture_stage_extracting to 0.9f
else -> R.string.capture_stage_uploading to 0.2f
}
ProgressStage(stringResource(labelRes), progress)
}
is CaptureUiState.Editing -> ReceiptReviewScreen(
initial = state.draft,
imageUri = state.imageUri,
saveLabel = stringResource(R.string.capture_save),
onSave = { edited -> viewModel.save(edited, onSaved) },
)
is CaptureUiState.Error -> Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.capture_stage_error, state.message))
Button(onClick = { viewModel.reset() }) { Text(stringResource(R.string.capture_retry)) }
}
}
}
}
/** Gallery picks come back as a content:// Uri, not a File - submit()
* needs a real File for the multipart upload, so copy it into the same
* internal-storage dir the camera flow writes to (not cacheDir - see
* res/xml/file_paths.xml's own comment on why this must be durable). */
private fun copyPickedImageToCache(context: Context, uri: Uri): File? {
val dir = File(context.filesDir, "receipts").apply { mkdirs() }
val file = File(dir, "picked-${System.currentTimeMillis()}.jpg")
return try {
context.contentResolver.openInputStream(uri)?.use { input ->
file.outputStream().use { output -> input.copyTo(output) }
} ?: return null
file
} catch (e: java.io.IOException) {
null
}
}
@Composable
private fun ProgressStage(label: String, progress: Float) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(label)
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
}
}
@@ -0,0 +1,15 @@
package de.ladkau.fuelish.ui.capture
import android.net.Uri
import de.ladkau.fuelish.data.ExtractedReceiptDto
sealed interface CaptureUiState {
data object Idle : CaptureUiState
data object Uploading : CaptureUiState
/** [stage] is one of JobStatusResponse's STATUS_* constants (preprocessing/ocr/extracting). */
data class Processing(val stage: String) : CaptureUiState
data class Editing(val draft: ExtractedReceiptDto, val imageUri: Uri?) : CaptureUiState
data class Error(val message: String) : CaptureUiState
}
@@ -0,0 +1,104 @@
package de.ladkau.fuelish.ui.capture
import android.app.Application
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import de.ladkau.fuelish.data.ConfigRepository
import de.ladkau.fuelish.data.ExtractedReceiptDto
import de.ladkau.fuelish.data.JobStatusResponse
import de.ladkau.fuelish.data.Receipt
import de.ladkau.fuelish.data.ReceiptRepository
import de.ladkau.fuelish.log.AppLog
import java.io.File
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
private const val POLL_INTERVAL_MILLIS = 1_500L
class CaptureViewModel(application: Application) : AndroidViewModel(application) {
private val repository = ReceiptRepository(application)
private val configRepository = ConfigRepository(application)
private val _uiState = MutableStateFlow<CaptureUiState>(CaptureUiState.Idle)
val uiState: StateFlow<CaptureUiState> = _uiState.asStateFlow()
// The captured photo's local URI, kept alongside the receipt once saved
// (see Receipt.imageUri) - set right before submit(), cleared on save()/reset().
private var pendingImageUri: Uri? = null
fun submit(imageFile: File, imageUri: Uri) {
pendingImageUri = imageUri
viewModelScope.launch {
_uiState.value = CaptureUiState.Uploading
try {
val baseUrl = configRepository.config.first().serverUrl
if (baseUrl.isBlank()) {
_uiState.value = CaptureUiState.Error("Set the server URL in Settings first")
return@launch
}
val jobId = repository.submitImage(baseUrl, imageFile)
pollUntilDone(baseUrl, jobId)
} catch (e: Exception) {
_uiState.value = CaptureUiState.Error(e.message ?: "unknown error")
}
}
}
private suspend fun pollUntilDone(baseUrl: String, jobId: String) {
var lastStage: String? = null
while (true) {
val status = repository.pollJob(baseUrl, jobId)
when (status.status) {
JobStatusResponse.STATUS_DONE -> {
AppLog.log("Job $jobId done")
_uiState.value = CaptureUiState.Editing(status.result ?: ExtractedReceiptDto(), pendingImageUri)
return
}
JobStatusResponse.STATUS_ERROR -> {
AppLog.log("Job $jobId failed: ${status.error}")
_uiState.value = CaptureUiState.Error(status.error ?: "processing failed")
return
}
else -> {
if (status.status != lastStage) {
AppLog.log("Job $jobId: ${status.status}")
lastStage = status.status
}
_uiState.value = CaptureUiState.Processing(status.status)
delay(POLL_INTERVAL_MILLIS)
}
}
}
}
fun save(draft: ExtractedReceiptDto, onSaved: () -> Unit) {
viewModelScope.launch {
repository.save(
Receipt(
date = draft.date,
time = draft.time,
pricePerLitre = draft.pricePerLitre,
litres = draft.litres,
discount = draft.discount,
totalPrice = draft.totalPrice,
imageUri = pendingImageUri?.toString(),
createdAtEpochMillis = System.currentTimeMillis(),
),
)
AppLog.log("Saved receipt (date=${draft.date}, total=${draft.totalPrice})")
pendingImageUri = null
_uiState.value = CaptureUiState.Idle
onSaved()
}
}
fun reset() {
pendingImageUri = null
_uiState.value = CaptureUiState.Idle
}
}
@@ -0,0 +1,82 @@
package de.ladkau.fuelish.ui.log
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import de.ladkau.fuelish.R
import de.ladkau.fuelish.log.AppLog
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun LogScreen() {
val entries by AppLog.entries.collectAsState()
val clipboard = LocalClipboardManager.current
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Button(
onClick = {
clipboard.setText(AnnotatedString(entries.joinToString("\n") { formatLogEntry(it) }))
},
) {
Text(stringResource(R.string.log_copy))
}
Button(onClick = { AppLog.clear() }) {
Text(stringResource(R.string.log_clear))
}
}
if (entries.isEmpty()) {
Text(stringResource(R.string.log_empty), modifier = Modifier.padding(16.dp))
} else {
// Newest first - the most recent event (usually what someone
// opened this screen to check) is what you want without scrolling.
// SelectionContainer: lets text be selected/copied by hand too,
// in addition to the "copy everything" button above.
SelectionContainer {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(entries.asReversed()) { entry ->
Text(
text = formatLogEntry(entry),
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
)
HorizontalDivider()
}
}
}
}
}
}
private val TIME_FORMAT = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
private fun formatLogEntry(entry: AppLog.Entry): String =
"${TIME_FORMAT.format(Date(entry.timestampMillis))} ${entry.message}"
@@ -0,0 +1,103 @@
package de.ladkau.fuelish.ui.overview
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.ladkau.fuelish.R
import de.ladkau.fuelish.data.Receipt
private val DELETE_COLUMN_WIDTH = 48.dp
@Composable
fun OverviewScreen(onReceiptClick: (Long) -> Unit, viewModel: OverviewViewModel = viewModel()) {
val receipts by viewModel.receipts.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
ReceiptTableHeader()
HorizontalDivider()
if (receipts.isEmpty()) {
Text(
text = stringResource(R.string.overview_empty),
modifier = Modifier.padding(16.dp),
)
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(receipts, key = { it.id }) { receipt ->
ReceiptRow(
receipt = receipt,
onClick = { onReceiptClick(receipt.id) },
onDelete = { viewModel.delete(receipt) },
)
HorizontalDivider()
}
}
}
}
}
@Composable
private fun ReceiptTableHeader() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.overview_column_date),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(R.string.overview_column_litres),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(R.string.overview_column_total),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.weight(1f),
)
Row(modifier = Modifier.width(DELETE_COLUMN_WIDTH)) {}
}
}
@Composable
private fun ReceiptRow(receipt: Receipt, onClick: () -> Unit, onDelete: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = receipt.date.orEmpty(), modifier = Modifier.weight(1f))
Text(text = receipt.litres?.let { "%.2f l".format(it) }.orEmpty(), modifier = Modifier.weight(1f))
Text(text = receipt.totalPrice?.let { "%.2f €".format(it) }.orEmpty(), modifier = Modifier.weight(1f))
IconButton(onClick = onDelete, modifier = Modifier.width(DELETE_COLUMN_WIDTH)) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.overview_delete))
}
}
}
@@ -0,0 +1,26 @@
package de.ladkau.fuelish.ui.overview
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import de.ladkau.fuelish.data.Receipt
import de.ladkau.fuelish.data.ReceiptRepository
import de.ladkau.fuelish.log.AppLog
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class OverviewViewModel(application: Application) : AndroidViewModel(application) {
private val repository = ReceiptRepository(application)
val receipts: StateFlow<List<Receipt>> = repository.receipts
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun delete(receipt: Receipt) {
viewModelScope.launch {
repository.delete(receipt)
AppLog.log("Deleted receipt (date=${receipt.date}, total=${receipt.totalPrice})")
}
}
}
@@ -0,0 +1,61 @@
package de.ladkau.fuelish.ui.receipt
import android.app.Application
import android.net.Uri
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import de.ladkau.fuelish.R
import de.ladkau.fuelish.data.ExtractedReceiptDto
/** Drill-down from the overview list into one saved receipt - the same
* Details/Photo UI as the post-scan review (ui/capture/CaptureScreen.kt),
* but editing an existing DB row (Update) instead of inserting a new one,
* plus a Delete action. */
@Composable
fun ReceiptDetailScreen(receiptId: Long, onDeleted: () -> Unit) {
val application = LocalContext.current.applicationContext as Application
val viewModel: ReceiptDetailViewModel = viewModel(
factory = viewModelFactory { initializer { ReceiptDetailViewModel(application, receiptId) } },
)
val receipt by viewModel.receipt.collectAsState()
val deleted by viewModel.deleted.collectAsState()
LaunchedEffect(deleted) {
if (deleted) onDeleted()
}
val current = receipt
if (current == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return
}
ReceiptReviewScreen(
initial = ExtractedReceiptDto(
date = current.date,
time = current.time,
pricePerLitre = current.pricePerLitre,
litres = current.litres,
discount = current.discount,
totalPrice = current.totalPrice,
),
imageUri = current.imageUri?.let { Uri.parse(it) },
saveLabel = stringResource(R.string.receipt_update),
onSave = { edited -> viewModel.update(edited) },
onDelete = { viewModel.delete() },
)
}
@@ -0,0 +1,58 @@
package de.ladkau.fuelish.ui.receipt
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import de.ladkau.fuelish.data.ExtractedReceiptDto
import de.ladkau.fuelish.data.Receipt
import de.ladkau.fuelish.data.ReceiptRepository
import de.ladkau.fuelish.log.AppLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/** Backs [ReceiptDetailScreen] - loads one already-saved [Receipt] by id
* and lets it be edited in place or deleted, reusing [ReceiptReviewScreen]
* (the same UI ui/capture/CaptureScreen.kt uses right after a scan). */
class ReceiptDetailViewModel(application: Application, private val receiptId: Long) : AndroidViewModel(application) {
private val repository = ReceiptRepository(application)
private val _receipt = MutableStateFlow<Receipt?>(null)
val receipt: StateFlow<Receipt?> = _receipt.asStateFlow()
private val _deleted = MutableStateFlow(false)
val deleted: StateFlow<Boolean> = _deleted.asStateFlow()
init {
viewModelScope.launch {
_receipt.value = repository.getById(receiptId)
}
}
fun update(edited: ExtractedReceiptDto) {
val current = _receipt.value ?: return
viewModelScope.launch {
val updated = current.copy(
date = edited.date,
time = edited.time,
pricePerLitre = edited.pricePerLitre,
litres = edited.litres,
discount = edited.discount,
totalPrice = edited.totalPrice,
)
repository.update(updated)
AppLog.log("Updated receipt id=$receiptId")
_receipt.value = updated
}
}
fun delete() {
val current = _receipt.value ?: return
viewModelScope.launch {
repository.delete(current)
AppLog.log("Deleted receipt id=$receiptId")
_deleted.value = true
}
}
}
@@ -0,0 +1,171 @@
package de.ladkau.fuelish.ui.receipt
import android.net.Uri
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.ladkau.fuelish.R
import de.ladkau.fuelish.data.ExtractedReceiptDto
import de.ladkau.fuelish.data.Plausibility
import de.ladkau.fuelish.data.checkPlausibility
private val PlausibleGreen = Color(0xFF2E7D32)
private val DubiousRed = Color(0xFFC62828)
/**
* Shared "look at one receipt" screen: an editable Details tab (with a
* live plausibility check) and a Photo tab with the source image, used
* both right after a scan (ui/capture/CaptureScreen.kt) and when drilling
* into an already-saved receipt from the overview list
* (ui/receipt/ReceiptDetailScreen.kt) - same UI either way, just a
* different [saveLabel] and whether [onDelete] is provided.
*/
@Composable
fun ReceiptReviewScreen(
initial: ExtractedReceiptDto,
imageUri: Uri?,
onSave: (ExtractedReceiptDto) -> Unit,
saveLabel: String,
onDelete: (() -> Unit)? = null,
) {
var date by remember(initial) { mutableStateOf(initial.date.orEmpty()) }
var time by remember(initial) { mutableStateOf(initial.time.orEmpty()) }
var pricePerLitre by remember(initial) { mutableStateOf(initial.pricePerLitre?.toString().orEmpty()) }
var litres by remember(initial) { mutableStateOf(initial.litres?.toString().orEmpty()) }
var discount by remember(initial) { mutableStateOf(initial.discount?.toString().orEmpty()) }
var totalPrice by remember(initial) { mutableStateOf(initial.totalPrice?.toString().orEmpty()) }
val plausibility = remember(pricePerLitre, litres, discount, totalPrice) {
checkPlausibility(pricePerLitre.toDoubleOrNull(), litres.toDoubleOrNull(), discount.toDoubleOrNull(), totalPrice.toDoubleOrNull())
}
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
Column(modifier = Modifier.fillMaxSize()) {
TabRow(selectedTabIndex = selectedTab) {
Tab(
selected = selectedTab == 0,
onClick = { selectedTab = 0 },
text = { Text(stringResource(R.string.receipt_tab_details)) },
)
Tab(
selected = selectedTab == 1,
onClick = { selectedTab = 1 },
text = { Text(stringResource(R.string.receipt_tab_photo)) },
)
}
when (selectedTab) {
0 -> Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
PlausibilityBadge(plausibility)
LabeledField(R.string.capture_field_date, date) { date = it }
LabeledField(R.string.capture_field_time, time) { time = it }
LabeledField(R.string.capture_field_price_per_litre, pricePerLitre) { pricePerLitre = it }
LabeledField(R.string.capture_field_litres, litres) { litres = it }
LabeledField(R.string.capture_field_discount, discount) { discount = it }
LabeledField(R.string.capture_field_total_price, totalPrice) { totalPrice = it }
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxWidth()
.align(Alignment.End),
) {
if (onDelete != null) {
OutlinedButton(onClick = onDelete) {
Text(stringResource(R.string.receipt_delete))
}
}
Button(
onClick = {
onSave(
ExtractedReceiptDto(
date = date.ifBlank { null },
time = time.ifBlank { null },
pricePerLitre = pricePerLitre.toDoubleOrNull(),
litres = litres.toDoubleOrNull(),
discount = discount.toDoubleOrNull(),
totalPrice = totalPrice.toDoubleOrNull(),
),
)
},
) {
Text(saveLabel)
}
}
}
else -> Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
if (imageUri != null) {
ZoomableImage(uri = imageUri, modifier = Modifier.fillMaxSize())
} else {
Text(stringResource(R.string.receipt_no_photo))
}
}
}
}
}
@Composable
private fun PlausibilityBadge(plausibility: Plausibility) {
val (color, textRes) = when (plausibility) {
Plausibility.OK -> PlausibleGreen to R.string.receipt_scan_ok
Plausibility.DUBIOUS -> DubiousRed to R.string.receipt_scan_dubious
Plausibility.UNKNOWN -> return
}
Surface(
color = color.copy(alpha = 0.15f),
contentColor = color,
shape = MaterialTheme.shapes.small,
) {
Text(
text = stringResource(textRes),
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
@Composable
private fun LabeledField(labelRes: Int, value: String, onValueChange: (String) -> Unit) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(stringResource(labelRes)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
@@ -0,0 +1,76 @@
package de.ladkau.fuelish.ui.receipt
import android.net.Uri
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.gestures.transformable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import de.ladkau.fuelish.R
/** Pinch-to-zoom + pan, with a double-tap to reset - the minimum viable
* "look closely at a receipt photo" viewer, no separate library needed
* (Compose's own transformable/pointerInput gestures cover it).
*
* Falls back to a text message rather than a blank area if the file is
* missing - e.g. an older receipt saved back when photos were written to
* the cache dir instead of internal storage, and the OS has since cleared
* it (see res/xml/file_paths.xml's own comment). */
@Composable
fun ZoomableImage(uri: Uri, modifier: Modifier = Modifier) {
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
val transformState = rememberTransformableState { zoomChange, panChange, _ ->
scale = (scale * zoomChange).coerceIn(1f, 6f)
offset += panChange
}
SubcomposeAsyncImage(
model = uri,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = modifier.fillMaxSize(),
loading = { Box(Modifier.fillMaxSize()) },
error = {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.receipt_photo_missing))
}
},
success = {
SubcomposeAsyncImageContent(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures(onDoubleTap = {
scale = 1f
offset = Offset.Zero
})
}
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offset.x,
translationY = offset.y,
)
.transformable(transformState),
)
},
)
}
@@ -0,0 +1,62 @@
package de.ladkau.fuelish.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.ladkau.fuelish.R
@Composable
fun ConfigScreen(viewModel: ConfigViewModel = viewModel()) {
val config by viewModel.config.collectAsState()
val isDirty by viewModel.isDirty.collectAsState()
val testResult by viewModel.testResult.collectAsState()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = config.serverUrl,
onValueChange = { viewModel.update { c -> c.copy(serverUrl = it) } },
label = { Text(stringResource(R.string.settings_server_url)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { viewModel.save() }, enabled = isDirty) {
Text(stringResource(R.string.capture_save))
}
OutlinedButton(onClick = { viewModel.testConnection() }) {
Text(stringResource(R.string.settings_test_connection))
}
}
when (val result = testResult) {
ConnectionTestResult.Testing -> Text(stringResource(R.string.settings_test_testing))
ConnectionTestResult.Success -> Text(
stringResource(R.string.settings_test_success),
color = MaterialTheme.colorScheme.primary,
)
is ConnectionTestResult.Failure -> Text(
stringResource(R.string.settings_test_failure, result.message),
color = MaterialTheme.colorScheme.error,
)
null -> Unit
}
}
}
@@ -0,0 +1,91 @@
package de.ladkau.fuelish.ui.settings
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import de.ladkau.fuelish.R
import de.ladkau.fuelish.data.ApiClient
import de.ladkau.fuelish.data.AppConfig
import de.ladkau.fuelish.data.ConfigRepository
import de.ladkau.fuelish.data.HealthCheckResult
import de.ladkau.fuelish.log.AppLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
sealed interface ConnectionTestResult {
data object Testing : ConnectionTestResult
data object Success : ConnectionTestResult
data class Failure(val message: String) : ConnectionTestResult
}
class ConfigViewModel(application: Application) : AndroidViewModel(application) {
private val configRepository = ConfigRepository(application)
private val api = ApiClient()
// A local editable draft, seeded once from the persisted config below -
// deliberately not a live collection of configRepository.config, so
// in-progress edits aren't clobbered by DataStore's own emission of the
// write this same screen is about to make on save().
private val _config = MutableStateFlow(AppConfig())
val config: StateFlow<AppConfig> = _config.asStateFlow()
private val _savedConfig = MutableStateFlow(AppConfig())
val isDirty: StateFlow<Boolean> = combine(_config, _savedConfig) { draft, saved -> draft != saved }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
private val _testResult = MutableStateFlow<ConnectionTestResult?>(null)
val testResult: StateFlow<ConnectionTestResult?> = _testResult.asStateFlow()
init {
viewModelScope.launch {
val loaded = configRepository.config.first()
// Prefill an editable example rather than leaving the field
// blank with just a placeholder, so there's something to
// select-and-overwrite instead of typing a URL from scratch.
val seeded = if (loaded.serverUrl.isBlank()) {
loaded.copy(serverUrl = getApplication<Application>().getString(R.string.settings_server_url_example))
} else {
loaded
}
_config.value = seeded
_savedConfig.value = loaded
}
}
fun update(transform: (AppConfig) -> AppConfig) {
_config.value = transform(_config.value)
_testResult.value = null
}
fun save() {
viewModelScope.launch {
val toSave = _config.value
configRepository.save(toSave)
_savedConfig.value = toSave
}
}
fun testConnection() {
val url = _config.value.serverUrl
viewModelScope.launch {
_testResult.value = ConnectionTestResult.Testing
when (val result = api.checkHealth(url)) {
is HealthCheckResult.Ok -> {
AppLog.log("Test connection to $url succeeded")
_testResult.value = ConnectionTestResult.Success
}
is HealthCheckResult.Failed -> {
AppLog.log("Test connection to $url failed: ${result.message}")
_testResult.value = ConnectionTestResult.Failure(result.message)
}
}
}
}
}
@@ -0,0 +1,30 @@
package de.ladkau.fuelish.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val LightColors = lightColorScheme()
private val DarkColors = darkColorScheme()
@Composable
fun FuelishTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val context = LocalContext.current
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
darkTheme -> DarkColors
else -> LightColors
}
MaterialTheme(colorScheme = colorScheme, content = content)
}
@@ -0,0 +1,5 @@
<?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="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?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="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Matches the white background baked into logo.png, so the
generated launcher icon / splash screen sit on the same
background the source art was designed against. -->
<color name="ic_launcher_background">#FFFFFF</color>
<color name="splash_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">fuelish</string>
<string name="nav_capture">Scan receipt</string>
<string name="nav_overview">Receipts</string>
<string name="nav_settings">Settings</string>
<string name="nav_log">App log</string>
<string name="nav_back">Back</string>
<string name="capture_take_photo">Take photo</string>
<string name="capture_choose_gallery">Choose from gallery</string>
<string name="capture_stage_uploading">Uploading…</string>
<string name="capture_stage_preprocessing">Preprocessing image…</string>
<string name="capture_stage_ocr">Reading text (OCR)…</string>
<string name="capture_stage_extracting">Extracting fields…</string>
<string name="capture_stage_error">Something went wrong: %1$s</string>
<string name="capture_retry">Retry</string>
<string name="capture_save">Save</string>
<string name="capture_field_date">Date (DD.MM.YYYY)</string>
<string name="capture_field_time">Time (HH:MM:SS)</string>
<string name="capture_field_price_per_litre">Price per litre</string>
<string name="capture_field_litres">Litres</string>
<string name="capture_field_discount">Discount</string>
<string name="capture_field_total_price">Total price</string>
<string name="receipt_tab_details">Details</string>
<string name="receipt_tab_photo">Photo</string>
<string name="receipt_no_photo">No photo available</string>
<string name="receipt_photo_missing">Photo file is missing</string>
<string name="receipt_scan_ok">Scan OK</string>
<string name="receipt_scan_dubious">Scan dubious</string>
<string name="receipt_update">Update</string>
<string name="receipt_delete">Delete</string>
<string name="overview_empty">No receipts have been scanned yet</string>
<string name="overview_column_date">Date</string>
<string name="overview_column_litres">Litres</string>
<string name="overview_column_total">Total</string>
<string name="overview_delete">Delete</string>
<string name="overview_export_csv">Export CSV</string>
<string name="overview_export_ods">Export ODS</string>
<string name="overview_export_done">Exported to %1$s</string>
<string name="settings_server_url">Server URL</string>
<string name="settings_server_url_example">http://192.168.1.10:8080</string>
<string name="settings_test_connection">Test connection</string>
<string name="settings_test_testing">Testing…</string>
<string name="settings_test_success">Connected successfully</string>
<string name="settings_test_failure">Could not reach server: %1$s</string>
<string name="status_connected">Connected to %1$s</string>
<string name="status_disconnected">Cannot reach %1$s</string>
<string name="status_not_configured">No server configured - see Settings</string>
<string name="status_checking">Checking connection…</string>
<string name="log_copy">Copy log</string>
<string name="log_clear">Clear log</string>
<string name="log_empty">Nothing logged yet</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Fuelish" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.Fuelish.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/splash_background</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="postSplashScreenTheme">@style/Theme.Fuelish</item>
</style>
</resources>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Where the FileProvider (see AndroidManifest.xml) is allowed to serve
content:// URIs from. -->
<paths>
<!-- Receipt photos (camera captures and gallery picks) - written to
internal storage, not the cache dir: Receipt.imageUri (see
data/Receipt.kt) is a durable DB reference to this file, and the
cache dir can be cleared by the OS at any time independent of the
database, which silently orphaned it (see ui/capture/CaptureScreen.kt's
own comment). -->
<files-path name="receipts" path="receipts/" />
<!-- Exported CSV/ODS files (see export/CsvExporter.kt, OdsExporter.kt),
shared out via an ACTION_SEND intent - regenerated on every
export, so cache is fine here. -->
<cache-path name="exports" path="exports/" />
</paths>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- fuelish talks to a self-hosted, LAN-only server with no TLS (see
AGENT.md / the server plan - auth and TLS are an explicit v1
non-goal for a personal LAN tool). Android's network security config
has no CIDR-range matcher, only literal domains/IPs, so rather than
enumerate private-range addresses we allow cleartext globally; the
app never talks to anything but the server URL the user enters in
Settings. -->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
+6
View File
@@ -0,0 +1,6 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false
}
+11
View File
@@ -0,0 +1,11 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
ksp.useKSP2=true
# AGP 9's built-in Kotlin support rejects KSP's use of the kotlin.sourceSets
# DSL to register generated-source dirs (Room's annotation processor, via
# KSP) - this is AGP/KSP's own suggested suppression, not a workaround for
# anything we did. See https://developer.android.com/r/tools/built-in-kotlin
android.disallowKotlinSourceSets=false
+58
View File
@@ -0,0 +1,58 @@
[versions]
# Version-lookup tools (WebFetch/registry browsing) were unavailable while
# this file was written, so these are last-known-good pins rather than a
# freshly verified "latest stable" snapshot - if Gradle can't resolve one,
# bump it to whatever's current on Google's Maven / Maven Central.
#
# kotlin/ksp are pinned to 2.3.x rather than 2.2.20: androidx.room:
# room-common-jvm:2.8.5 declares a `{strictly 2.3.20}` constraint on
# kotlin-stdlib, which would otherwise silently resolve above what a
# 2.2.20 compiler/KSP understands. Keep these two in sync if Room is ever
# bumped further.
agp = "9.2.1"
kotlin = "2.3.20"
# KSP's versioning stopped mirroring Kotlin's exact patch after the 2.3.0
# line (no "2.3.20" KSP release exists) - 2.3.12 is the latest in the same
# 2.3.x line as kotlin above.
ksp = "2.3.12"
coreKtx = "1.17.0"
lifecycleRuntimeKtx = "2.9.4"
activityCompose = "1.13.0"
composeBom = "2026.06.01"
navigationCompose = "2.9.8"
datastorePreferences = "1.2.1"
kotlinxSerializationJson = "1.11.0"
kotlinxCoroutines = "1.11.0"
room = "2.8.5"
splashscreen = "1.0.1"
okhttp = "4.12.0"
coil = "3.0.4"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Generates the Android launcher icon and splash screen art from
../../logo.png (the repo's brand mark + "fuelish" wordmark).
Only the graphic mark (not the wordmark) works as a launcher/splash icon,
so this first isolates it: crop the upper portion of the source image
(where the mark lives) and auto-crop that to its actual non-transparent
bounding box, rather than relying on any hardcoded pixel offsets that
would break if logo.png is ever redrawn at a different resolution.
Produces:
- mipmap-<density>/ic_launcher_foreground.png - the mark centered, scaled
to the adaptive-icon safe zone (66% of a 108dp canvas), transparent
background - paired at runtime with @color/ic_launcher_background (see
mipmap-anydpi-v26/ic_launcher.xml, committed - a static reference, not
generated) for API 26+.
- mipmap-<density>/ic_launcher.png / ic_launcher_round.png - the same
mark flattened onto ic_launcher_background, for API <26 devices that
can't use the adaptive-icon XML at all.
- drawable-nodpi/splash_icon.png - the mark on a transparent background,
referenced by Theme.Fuelish.Splash's windowSplashScreenAnimatedIcon
(see values/themes.xml).
Generated output, not committed (see the repo's root .gitignore's
/android/app/src/main/res/{mipmap-*,drawable-nodpi} entries) - regenerate
by running this script directly or via Gradle's generateAppIcons task
(wired into app's preBuild, see ../app/build.gradle.kts). Requires
Pillow.
"""
import os
import sys
from PIL import Image
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
LOGO_PATH = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "logo.png"))
RES_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "app", "src", "main", "res"))
BACKGROUND_RGBA = (255, 255, 255, 255) # matches @color/ic_launcher_background
# (density qualifier, adaptive-icon canvas px, legacy launcher px)
DENSITIES = [
("mdpi", 108, 48),
("hdpi", 162, 72),
("xhdpi", 216, 96),
("xxhdpi", 324, 144),
("xxxhdpi", 432, 192),
]
# Adaptive icons render only the inner ~66% "safe zone" reliably across
# launchers that apply their own mask shape.
SAFE_ZONE_RATIO = 0.66
# Fraction of logo.png's height (from the top) that contains the graphic
# mark, before the "fuelish" wordmark begins.
MARK_REGION_HEIGHT_RATIO = 0.60
SPLASH_CANVAS_PX = 480
SPLASH_ICON_RATIO = 0.75
def extract_mark(logo: Image.Image) -> Image.Image:
width, height = logo.size
region = logo.crop((0, 0, width, round(height * MARK_REGION_HEIGHT_RATIO)))
bbox = region.getchannel("A").getbbox()
if bbox is None:
raise ValueError(f"{LOGO_PATH}: no non-transparent pixels found in the top "
f"{MARK_REGION_HEIGHT_RATIO:.0%} of the image")
mark = region.crop(bbox)
# Pad to square so downstream scaling doesn't distort the aspect ratio.
side = max(mark.width, mark.height)
square = Image.new("RGBA", (side, side), (0, 0, 0, 0))
square.paste(mark, ((side - mark.width) // 2, (side - mark.height) // 2), mark)
return square
def resized(image: Image.Image, target_px: int) -> Image.Image:
ratio = target_px / max(image.width, image.height)
size = (round(image.width * ratio), round(image.height * ratio))
return image.resize(size, Image.LANCZOS)
def centered_on(canvas_px: int, content: Image.Image, background: tuple) -> Image.Image:
canvas = Image.new("RGBA", (canvas_px, canvas_px), background)
offset = ((canvas_px - content.width) // 2, (canvas_px - content.height) // 2)
canvas.paste(content, offset, content)
return canvas
def main():
logo = Image.open(LOGO_PATH).convert("RGBA")
mark = extract_mark(logo)
for density, canvas_px, legacy_px in DENSITIES:
mipmap_dir = os.path.join(RES_DIR, f"mipmap-{density}")
os.makedirs(mipmap_dir, exist_ok=True)
foreground = centered_on(canvas_px, resized(mark, round(canvas_px * SAFE_ZONE_RATIO)), (0, 0, 0, 0))
foreground.save(os.path.join(mipmap_dir, "ic_launcher_foreground.png"))
legacy = centered_on(legacy_px, resized(mark, round(legacy_px * SAFE_ZONE_RATIO)), BACKGROUND_RGBA)
legacy.convert("RGB").save(os.path.join(mipmap_dir, "ic_launcher.png"))
legacy.convert("RGB").save(os.path.join(mipmap_dir, "ic_launcher_round.png"))
drawable_dir = os.path.join(RES_DIR, "drawable-nodpi")
os.makedirs(drawable_dir, exist_ok=True)
splash = centered_on(SPLASH_CANVAS_PX, resized(mark, round(SPLASH_CANVAS_PX * SPLASH_ICON_RATIO)), (0, 0, 0, 0))
splash.save(os.path.join(drawable_dir, "splash_icon.png"))
print(f"wrote launcher icons for {len(DENSITIES)} densities + splash icon to {RES_DIR}", file=sys.stderr)
if __name__ == "__main__":
main()
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
// Auto-provisions the JDK 17 toolchain app/build.gradle.kts requests (see
// its jvmToolchain(17) call) - this host's only JDKs otherwise are system
// JDK 11 (too old for AGP 9) and Android Studio's bundled JBR 25 (newer
// than Kotlin's compiler officially targets yet).
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "fuelish-android"
include(":app")
Regular → Executable
+30 -12
View File
@@ -1,7 +1,11 @@
#!/usr/bin/env bash
# Builds the gastronomy tools image (tooling-container/Dockerfile) locally, tagged with
# tooling-container/VERSION and :latest. Does not push - test it with the numbered
# pipeline scripts first, then publish with ./upload-image.sh.
# Builds every image this repo's pipeline/CI needs: the fuelish tools
# image (tooling-container/Dockerfile) and the Android CI/release build
# image (build-image/Dockerfile), each tagged from its own VERSION file,
# plus pulls ollama/ollama (used by 0_run_ollama.sh). Does not push - test
# the tools image with the numbered pipeline scripts and the
# android-builder image with ./run-image.sh, then publish both with
# ./upload-image.sh.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
@@ -12,23 +16,37 @@ fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
command -v docker >/dev/null 2>&1 \
|| fail "docker not found in PATH"
IMAGE="${REGISTRY_IMAGE:-gastronomy/tools}"
TOOLS_IMAGE="${REGISTRY_IMAGE:-fuelish/tools}"
ANDROID_IMAGE="${REGISTRY_ANDROID_IMAGE:-cr.ladkau.de/fuelish/android-builder}"
if [ -f "$ROOT/registry.env" ]; then
# shellcheck disable=SC1091
source "$ROOT/registry.env"
IMAGE="${REGISTRY_IMAGE:-$IMAGE}"
TOOLS_IMAGE="${REGISTRY_IMAGE:-$TOOLS_IMAGE}"
ANDROID_IMAGE="${REGISTRY_ANDROID_IMAGE:-$ANDROID_IMAGE}"
fi
VERSION="$(<"$ROOT/tooling-container/VERSION")"
[ -n "$VERSION" ] || fail "tooling-container/VERSION is empty"
TOOLS_VERSION="$(<"$ROOT/tooling-container/VERSION")"
[ -n "$TOOLS_VERSION" ] || fail "tooling-container/VERSION is empty"
echo "== Building $IMAGE:$VERSION =="
ANDROID_VERSION="$(<"$ROOT/build-image/VERSION")"
[ -n "$ANDROID_VERSION" ] || fail "build-image/VERSION is empty"
echo "== Building $TOOLS_IMAGE:$TOOLS_VERSION =="
docker build \
-f "$ROOT/tooling-container/Dockerfile" \
-t "$IMAGE:$VERSION" \
-t "$IMAGE:latest" \
-t "$TOOLS_IMAGE:$TOOLS_VERSION" \
-t "$TOOLS_IMAGE:latest" \
"$ROOT/tooling-container"
echo "== Building $ANDROID_IMAGE:$ANDROID_VERSION =="
docker build \
-t "$ANDROID_IMAGE:$ANDROID_VERSION" \
-t "$ANDROID_IMAGE:latest" \
"$ROOT/build-image"
echo "== Pulling ollama/ollama (used by 0_run_ollama.sh) =="
docker pull ollama/ollama
echo "== Done =="
echo "Image: $IMAGE:$VERSION (and :latest)"
echo "Test it with the numbered pipeline scripts, then publish with ./upload-image.sh"
echo "Images: $TOOLS_IMAGE:$TOOLS_VERSION, $ANDROID_IMAGE:$ANDROID_VERSION (both also :latest)"
echo "Test the tools image with the numbered pipeline scripts, the android-builder image with ./run-image.sh, then publish both with ./upload-image.sh"
+73
View File
@@ -0,0 +1,73 @@
# Build environment for the Android app (android/): JDK + Android SDK
# cmdline-tools, pinned to the versions validated on the maintainer's dev
# machine (matching android/app/build.gradle.kts's compileSdk). Used both
# by .gitea/workflows/build.yml's CI container and by ../run-image.sh for
# a local, CI-equivalent build. Rebuild with ../build-image.sh whenever a
# version below changes.
#
# fuelish's Android app has no native/NDK code, so unlike a build image
# that also needs an NDK/CMake toolchain, this is just a JDK + the SDK's
# own cmdline-tools/platform/build-tools.
#
# Does NOT contain any secrets - registry/SFTP credentials are injected
# at job runtime from Gitea Actions secrets, never baked into this image.
FROM ubuntu:24.04
ARG NODE_VERSION=24.16.0
ARG ANDROID_CMDLINE_TOOLS_VERSION=9862592
ARG ANDROID_PLATFORM_VERSION=36
ARG ANDROID_BUILD_TOOLS_VERSION=36.0.0
ENV DEBIAN_FRONTEND=noninteractive
# openjdk-17-jdk-headless: Gradle/AGP's own minimum JDK (see
# android/app/build.gradle.kts's compileOptions).
# make: the root Makefile's `android` target (run by both run-image.sh
# and .gitea/workflows/build.yml).
# python3/python3-pil: android/scripts/gen_app_icons.py's Pillow
# dependency (see android/app/build.gradle.kts's generateAppIcons
# preBuild task) - apt's python3-pil is used instead of pip/venv since
# nothing else in this image needs a Python environment.
# git/curl/ca-certificates/unzip/xz-utils: checkout and SDK/Node tool
# downloads (xz-utils: Node's release tarball below is .tar.xz).
# openssh-client: the build workflow's `sftp` publish to dl.ladkau.de.
RUN apt-get update && apt-get install -y --no-install-recommends \
openjdk-17-jdk-headless make python3 python3-pil \
git curl ca-certificates unzip xz-utils openssh-client \
&& rm -rf /var/lib/apt/lists/*
# Node.js: actions/checkout@v4 and actions/upload-artifact@v3 (used by
# .gitea/workflows/build.yml) are both JS actions - Gitea's act_runner
# execs them with whatever `node` is on this image's PATH rather than
# injecting one itself (https://github.com/nektos/act/issues/107), and
# this base image doesn't bring one.
RUN curl -sSL -o /tmp/node.tar.xz \
"https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \
&& tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 \
&& rm /tmp/node.tar.xz
# Android SDK: the cmdline-tools' own sdkmanager installs exactly the
# platform/build-tools version android/app/build.gradle.kts pins -
# resolved here so no `docker run` of this image needs network access to
# install anything before `./gradlew assembleDebug` works. The Gradle
# build itself still needs network access at build time (Gradle/AGP/
# androidx dependency resolution against Google's Maven + Maven Central) -
# android/gradlew resolves its own pinned Gradle version, no Gradle
# binary is baked into this image.
ENV ANDROID_HOME=/opt/android-sdk \
ANDROID_SDK_ROOT=/opt/android-sdk
ENV PATH=${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${PATH}
RUN mkdir -p "${ANDROID_HOME}/cmdline-tools" \
&& curl -sSL -o /tmp/cmdline-tools.zip \
"https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" \
&& unzip -q /tmp/cmdline-tools.zip -d "${ANDROID_HOME}/cmdline-tools" \
&& mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" \
&& rm /tmp/cmdline-tools.zip \
&& yes | sdkmanager --licenses >/dev/null \
&& sdkmanager --install \
"platform-tools" \
"platforms;android-${ANDROID_PLATFORM_VERSION}" \
"build-tools;${ANDROID_BUILD_TOOLS_VERSION}"
WORKDIR /workspace
+1
View File
@@ -0,0 +1 @@
0.1.0
+35
View File
@@ -0,0 +1,35 @@
# Runs the pipeline as a long-lived HTTP API for the Android app, on top
# of the same tooling-container image the numbered host scripts use.
# `docker compose up` alone is enough: the api service waits for Ollama
# and pulls the model itself (see tooling-container/src/server.py).
#
# The numbered host scripts (0_run_ollama.sh, 1/2/3_*.sh) and
# test_batch.sh are untouched and remain the way to inspect/test the
# pipeline stage by stage; this is the additive "serve it for real" path.
#
# Usage: docker compose up -d
# API reachable at http://<this-host>:8080 from any device on the LAN.
services:
ollama:
image: ollama/ollama
volumes:
- fuelish-ollama-data:/root/.ollama
restart: unless-stopped
api:
build:
context: ./tooling-container
restart: unless-stopped
depends_on:
- ollama
environment:
OLLAMA_URL: http://ollama:11434/api/chat
OLLAMA_BASE_URL: http://ollama:11434
OLLAMA_MODEL: qwen2.5:3b
command: ["uvicorn", "server:app", "--app-dir", "/opt/app/src", "--host", "0.0.0.0", "--port", "8000"]
ports:
- "8080:8000"
volumes:
fuelish-ollama-data:
name: fuelish-ollama-data
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

+16 -6
View File
@@ -1,9 +1,19 @@
# Copy to registry.env (gitignored, never commit the real values) and fill
# in your registry credentials. Used by build-image.sh, the numbered
# pipeline scripts, and upload-image.sh. Only REGISTRY_IMAGE is needed for
# local builds/runs; REGISTRY/REGISTRY_USER/REGISTRY_PASSWORD are only
# needed to push with upload-image.sh.
REGISTRY=
REGISTRY_IMAGE=gastronomy/tools
# in your registry credentials. Used by build-image.sh, upload-image.sh,
# run-image.sh, and the numbered pipeline scripts.
#
# REGISTRY_IMAGE (the OCR pipeline's tools image, tooling-container/) and
# REGISTRY_ANDROID_IMAGE (the Android CI/release build image,
# build-image/) must both be fully-qualified with the registry host -
# upload-image.sh does `docker push "$REGISTRY_IMAGE:..."` directly, so
# an unqualified name (e.g. "fuelish/tools") silently resolves to Docker
# Hub instead of $REGISTRY and fails to push there ("insufficient_scope:
# authorization failed") even though `docker login "$REGISTRY"`
# succeeded. Adjust the host below if your registry isn't cr.ladkau.de.
# REGISTRY/REGISTRY_USER/REGISTRY_PASSWORD are only needed to push with
# upload-image.sh.
REGISTRY=cr.ladkau.de
REGISTRY_IMAGE=cr.ladkau.de/fuelish/tools
REGISTRY_ANDROID_IMAGE=cr.ladkau.de/fuelish/android-builder
REGISTRY_USER=
REGISTRY_PASSWORD=
Executable
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Runs `make android` inside the local android-builder image, mirroring
# what the Gitea Actions build workflow does - useful for testing
# build-image/ changes locally before pushing anything to a registry.
#
# Usage: ./run-image.sh [VERSION]
# VERSION is passed through to `make android` (see the Makefile's
# `android` target); omit it for its own git-tag-based default.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
ROOT="$(pwd)"
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
command -v docker >/dev/null 2>&1 \
|| fail "docker not found in PATH"
IMAGE="${REGISTRY_ANDROID_IMAGE:-cr.ladkau.de/fuelish/android-builder}"
if [ -f "$ROOT/registry.env" ]; then
# shellcheck disable=SC1091
source "$ROOT/registry.env"
IMAGE="${REGISTRY_ANDROID_IMAGE:-$IMAGE}"
fi
IMAGE_TAG="$(<"$ROOT/build-image/VERSION")"
[ -n "$IMAGE_TAG" ] || fail "build-image/VERSION is empty"
IMAGE="$IMAGE:$IMAGE_TAG"
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|| fail "$IMAGE not found locally - run ./build-image.sh first"
VERSION="${1:-${VERSION:-}}"
# CI always starts from a fresh checkout, but this script bind-mounts the
# live host repo - so build output left over from a previous local run
# (dist/, android/build, the generated app icon/splash resources) would
# make this run skip work a real fresh checkout wouldn't, silently hiding
# bugs that only show up in CI. Wipe them first so every run exercises a
# true from-scratch build, same as CI.
echo "== Cleaning generated build artifacts for a fresh build =="
rm -rf \
"$ROOT/dist" "$ROOT/android/build" "$ROOT/android/app/build" \
"$ROOT/android/app/src/main/res/drawable-nodpi" \
"$ROOT/android/app/src/main/res"/mipmap-mdpi "$ROOT/android/app/src/main/res"/mipmap-hdpi \
"$ROOT/android/app/src/main/res"/mipmap-xhdpi "$ROOT/android/app/src/main/res"/mipmap-xxhdpi \
"$ROOT/android/app/src/main/res"/mipmap-xxxhdpi
echo "== Running make android inside $IMAGE =="
# The container runs as root (needed for the SDK/Gradle caches baked into
# the image), so anything it writes into this bind mount - dist/,
# android/build, android/.gradle, etc. - would otherwise come back owned
# by root, leaving the host repo unusable without sudo. Chown everything
# back to the host user on exit, whether the build succeeds or fails.
docker run --rm \
-v "$ROOT:/workspace" \
-w /workspace \
-e VERSION="$VERSION" \
-e HOST_UID="$(id -u)" \
-e HOST_GID="$(id -g)" \
"$IMAGE" \
bash -c '
git config --global --add safe.directory /workspace
trap "chown -R \"$HOST_UID:$HOST_GID\" /workspace" EXIT
make android
'
echo "== Done - artifact in dist/ =="
ls -la "$ROOT/dist"
Regular → Executable
+12 -10
View File
@@ -1,17 +1,20 @@
#!/usr/bin/env bash
# Test-only tool: runs the full container pipeline (1_preprocess_image.sh,
# 2_ocr_image.sh, 3_extract_data.sh) over every *.jpg in a test-data
# directory, then compares the results against that directory's
# ground-truth spreadsheet (benzin_kosten.ods). Everything -
# Test-only tool: runs the full container pipeline
# (12_adaptive_preprocess_ocr.sh, 3_extract_data.sh) over every *.jpg in
# a test-data directory, then compares the results against that
# directory's ground-truth spreadsheet (benzin_kosten.ods). Everything -
# preprocessing, OCR, structuring, and the comparison itself - runs
# inside the tooling container; nothing here depends on a host Python
# environment.
#
# For an input directory <path>/<name>, two sibling directories are
# created (mirroring <path>/<name>):
# <path>/<name>-scratch/<receipt>/ - intermediate step output
# (01_gray.png, 02_bilateral.png,
# 03_final.png, ocr.txt) for
# <path>/<name>-scratch/<receipt>/ - intermediate step output: one
# variant<N>/ subdir per
# preprocessing variant tried
# (01_gray.png, ..., 0X_final.png,
# ocr.txt each) plus ocr.txt for
# the winning variant, for
# inspecting each step on its own
# <path>/<name>-result/<receipt>/ - final extracted.json per receipt,
# plus summary.txt with the overall
@@ -56,15 +59,14 @@ for img in "${IMAGES[@]}"; do
receipt_result="$RESULT_DIR/$name"
mkdir -p "$receipt_scratch" "$receipt_result"
echo "--- $name ---"
./1_preprocess_image.sh -in "$img" -out "$receipt_scratch" >/dev/null
./2_ocr_image.sh -in "$receipt_scratch/03_final.png" -out "$receipt_scratch" >/dev/null
./12_adaptive_preprocess_ocr.sh -in "$img" -out "$receipt_scratch" >/dev/null
./3_extract_data.sh -in "$receipt_scratch/ocr.txt" -out "$receipt_result" >/dev/null
done
echo
echo "== Comparing against ground truth =="
IMAGE="${REGISTRY_IMAGE:-gastronomy/tools}"
IMAGE="${REGISTRY_IMAGE:-fuelish/tools}"
if [ -f "$ROOT/registry.env" ]; then
# shellcheck disable=SC1091
source "$ROOT/registry.env"
Executable
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Test-only tool: runs preprocessing + OCR (1_preprocess_image.sh,
# 2_ocr_image.sh - variant 0/baseline only, deliberately not
# 12_adaptive_preprocess_ocr.sh) on a single receipt photo and prints the
# raw OCR text to stdout - no LLM extraction step, no ground-truth
# comparison, and no files left behind (unlike test_single.sh/
# test_batch.sh, which persist scratch/result directories and go through
# the real adaptive pipeline). Useful for quickly checking what Tesseract
# actually reads off one specific preprocessing variant in isolation -
# e.g. while iterating on tooling-container/src/preprocess.py - without
# the adaptive step's variant selection in the way, waiting on the LLM
# call, or needing ground truth.
#
# Usage: ./test_ocr.sh -in <photo.jpg>
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
fail() { echo "FAIL: $*" >&2; exit 1; }
IN_PATH=""
while [ $# -gt 0 ]; do
case "$1" in
-in) IN_PATH="${2:-}"; shift 2 ;;
*) fail "unknown argument: $1" ;;
esac
done
[ -n "$IN_PATH" ] || fail "usage: $0 -in <photo.jpg>"
[ -f "$IN_PATH" ] || fail "input file not found: $IN_PATH"
SCRATCH_DIR="$(mktemp -d)"
trap 'rm -rf "$SCRATCH_DIR"' EXIT
./1_preprocess_image.sh -in "$IN_PATH" -out "$SCRATCH_DIR" >/dev/null
./2_ocr_image.sh -in "$SCRATCH_DIR/03_final.png" -out "$SCRATCH_DIR" >/dev/null
cat "$SCRATCH_DIR/ocr.txt"
Executable
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Test-only tool: runs the full container pipeline
# (12_adaptive_preprocess_ocr.sh, 3_extract_data.sh) on a single receipt
# photo - the test_batch.sh equivalent for re-checking one receipt (e.g.
# a known failure, after a preprocessing/prompt tweak) without re-running
# a whole directory. If a ground-truth spreadsheet (*.ods) is found next
# to the photo, compares the result against it the same way
# test_batch.sh does; otherwise just prints the extracted JSON.
# Everything - preprocessing, OCR, structuring, and the comparison itself
# - runs inside the tooling container; nothing here depends on a host
# Python environment.
#
# For an input photo <path>/<name>.jpg, two sibling directories are
# created (mirroring test_batch.sh's layout, just for one receipt):
# <path>/<name>-scratch/<name>/ - intermediate step output: one
# variant<N>/ subdir per preprocessing
# variant tried, plus ocr.txt for the
# winning variant
# <path>/<name>-result/<name>/ - extracted.json, plus summary.txt
# if a ground-truth .ods was found
#
# Usage: ./test_single.sh -in <photo.jpg>
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
ROOT="$(pwd)"
fail() { echo "FAIL: $*" >&2; exit 1; }
IN_PATH=""
while [ $# -gt 0 ]; do
case "$1" in
-in) IN_PATH="${2:-}"; shift 2 ;;
*) fail "unknown argument: $1" ;;
esac
done
[ -n "$IN_PATH" ] || fail "usage: $0 -in <photo.jpg>"
[ -f "$IN_PATH" ] || fail "input file not found: $IN_PATH"
IN_ABS="$(realpath "$IN_PATH")"
IN_DIR="$(dirname "$IN_ABS")"
NAME="$(basename "${IN_ABS%.jpg}")"
SCRATCH_DIR="$IN_DIR/$NAME-scratch"
RESULT_DIR="$IN_DIR/$NAME-result"
receipt_scratch="$SCRATCH_DIR/$NAME"
receipt_result="$RESULT_DIR/$NAME"
mkdir -p "$receipt_scratch" "$receipt_result"
echo "== Running pipeline on $NAME =="
./12_adaptive_preprocess_ocr.sh -in "$IN_ABS" -out "$receipt_scratch" >/dev/null
./3_extract_data.sh -in "$receipt_scratch/ocr.txt" -out "$receipt_result" >/dev/null
echo
echo "== Extracted =="
cat "$receipt_result/extracted.json"
echo
ODS_PATH="$(find "$IN_DIR" -maxdepth 1 -name '*.ods' | head -n1)"
if [ -n "$ODS_PATH" ]; then
echo
echo "== Comparing against ground truth =="
IMAGE="${REGISTRY_IMAGE:-fuelish/tools}"
if [ -f "$ROOT/registry.env" ]; then
# shellcheck disable=SC1091
source "$ROOT/registry.env"
IMAGE="${REGISTRY_IMAGE:-$IMAGE}"
fi
VERSION="$(<"$ROOT/tooling-container/VERSION")"
IMAGE="$IMAGE:$VERSION"
ODS_DIR="$(dirname "$ODS_PATH")"
ODS_FILE="$(basename "$ODS_PATH")"
GROUP_ADD_ARGS=(--group-add "$(stat -c %g "$ODS_DIR")")
RESULT_GID="$(stat -c %g "$RESULT_DIR")"
[ "$RESULT_GID" = "$(stat -c %g "$ODS_DIR")" ] || GROUP_ADD_ARGS+=(--group-add "$RESULT_GID")
docker run --rm \
--user "$(id -u):$(id -g)" \
"${GROUP_ADD_ARGS[@]}" \
-v "$ODS_DIR:/data/gt:ro" \
-v "$RESULT_DIR:/data/results:ro" \
"$IMAGE" \
python3 /opt/app/src/compare_ground_truth.py -gt "/data/gt/$ODS_FILE" -results /data/results \
| tee "$RESULT_DIR/summary.txt"
else
echo "(no ground-truth .ods found next to $IN_PATH - skipping comparison)"
fi
echo
echo "== Done =="
echo "Intermediate step output: $receipt_scratch/"
echo "Final result: $receipt_result/extracted.json"
@@ -0,0 +1,82 @@
# 3-variant OCR cross-referencing experiment
Tested whether running Tesseract over three image variants of the same
receipt photo (original color photo, plain grayscale, and the existing
denoised/contrast-enhanced preprocessing) and asking the extraction LLM
to cross-reference all three per field would recover more correct data
than the single-OCR-pass baseline, particularly for blurry/low-quality
photos.
## Result
Mixed. One more receipt matched overall, two fields improved
meaningfully, but the original target field (`litres`) did not improve,
and `total_price` regressed.
| Metric | Baseline (1 OCR pass) | New (3-variant cross-ref) |
|---|---|---|
| Matched to ground truth | 15/17 | 16/17 |
| price_per_litre | 11/15 (73%) | 13/16 (81%) ↑ |
| litres | 9/15 (60%) | 9/16 (56%) — unchanged, still worst |
| discount | 11/15 (73%) | 15/16 (94%) ↑↑ |
| total_price | 12/15 (80%) | 11/16 (69%) ↓ |
## Root causes identified
1. **The litres/price-per-litre line is the hardest OCR target**, regardless
of variant count. Noise characters (`x2p 02`, `*/p`) precede the real
numbers and the litre symbol `l` reads as `1`/`]`/`|` in all three
variants alike, so this field stays wrong at the same rate.
2. **Three OCR readings don't guarantee the model picks the right one.**
On the blurry Jet receipt, the correct total `57,13` survives intact in
the original-photo variant, but the model still output `37.13`. Extra
(noisy) input sometimes confuses the small 3B model rather than helping
it reconcile.
3. **`total_price` regressed** — cross-referencing conflicting reads
apparently made the model second-guess fields it used to get right from
a single clean reading.
4. **Severe performance/stability cost** on this 2-core, GPU-less VM:
extraction latency became wildly inconsistent (1–19 minutes per receipt
vs. seconds before), and one run hit a hard timeout before the request
timeout was raised from 300s → 900s. The full 17-receipt batch that used
to finish in ~11 minutes now takes well over an hour.
## Working examples (fully correct — all 4 fields + date matched)
- `IMG_20260726_095120207`, `095321692`, `095428941`, `095442114`,
`095510481`, `095519964` — all Shell receipts with clean, high-contrast
OCR text across all three variants.
## Not-working examples
- **`IMG_20260525_182252232`** (the blurry Jet receipt that prompted this
experiment) — still no date match at all (extracted `13.05.2026` vs. true
`15.05.2026`). All three OCR variants scramble the header; even though
the correct total `57,13` is legible in the original-photo variant, the
model output `37.13` instead.
- **`IMG_20260525_182144541`** — worst case, everything wrong except
date/time/transaction number: extracted `litres=1.205` (should be ~23),
`price_per_litre=1.389` (plausible alone, but paired with nonsense
litres), `discount=-0.08` (sign flipped), `total_price=5.33`. All three
OCR variants are near-unreadable for this receipt.
- **`IMG_20260525_182116766`** — `litres` read correctly (`20.24`, matches
the OCR text `"20,24 1 1979 EUR/]"` cleanly) but `price_per_litre` came
out as `6.01` instead of `~1.979` — a pure extraction/reconciliation
error, not an OCR gap, since the right number is sitting right there in
the text.
- **`IMG_20260726_095312079`, `095335197`, `095416655`, `095452747`,
`095501803`** — all wrong only on `litres`, the recurring weak field.
- **`IMG_20260726_095215757`** — wrong on `litres` and `total_price`
together; the OCR text for that line is heavily corrupted
(`j 2 N N 4) ha 1: Hi A 1.869 EUR/`) in all three variants, so this one
genuinely lacks clean signal anywhere.
## Conclusion / recommendation
The extra OCR variants help in a few places (`discount`, `price_per_litre`)
but don't fix the core problem (`litres` line noise), introduce a new
failure mode (the model mis-reconciling conflicting-but-sometimes-correct
reads), and cost a lot of latency/stability on this hardware. Leaning
toward reverting to single-OCR unless a confidence-triggered fallback
(only pull extra variants when the primary reading looks incomplete) is
tried instead.
@@ -0,0 +1,182 @@
# GIMP High Pass + NL Filter preprocessing experiment
Tested whether a GIMP sharpening/denoising combo, applied to the photo
*before* our existing `preprocess.py` (grayscale → bilateral filter →
percentile autocontrast), recovers the compact "litres / EUR-per-litre"
price line that's the pipeline's worst-performing OCR target (see
`AGENT.md`'s "Known remaining failure modes"). Three stress-test receipts
with known-bad baseline OCR on that line (`testdata/2026_stress_test/`,
not checked in):
- **A** `IMG_20260525_182252232` — ground truth 29,31 l / 1,949 EUR/l
- **B** `IMG_20260525_182144541` — ground truth 17,88 l / 1,989 EUR/l
- **C** `IMG_20260929_182218993` — ground truth 18,86 l / 2,129 EUR/l
The recipe (discovered manually in GIMP's GUI, applied to the full
uncropped color photo):
1. **Gaussian Blur** (Size X=Y=4.15, Filter=Auto, Abyss=Clamp) with
**Blending Options: Mode=Grain Extract, Opacity=100** set directly in
the filter dialog — GIMP 2.10's GEGL-based "High Pass" filter has no
Script-Fu/PDB wrapper at all, so this is a Script-Fu-reachable
reproduction of it via the classic manual technique (blur, then
grain-extract-blend against the original).
2. **Levels**: input low=58, gamma=0.96, high=135; output 0–255.
3. **NL Filter**, filter-type=2 (edge-enhancement), alpha=0.69,
radius=0.52.
## Result
Every variant beat the pipeline's baseline (no GIMP step) on at least one
field, for all three images — confirming the general direction helps.
But no single step generalized on its own; only the full 2-step recipe
was consistently good-to-exact across all three:
| Image (ground truth) | Baseline | 1. Blur+GrainExtract | 2. +Levels | 3. NL Filter alone | 4. Full recipe (2+3) |
|---|---|---|---|---|---|
| **A** (29,31l / 1,949) | both unreadable | price≈missing, litres✗ | litres 29,91✗(1 digit) / price **1,949✓** | litres 28,31✗(1 digit) / price 1,983≈ | litres **29,31✓** / price 1,993≈ |
| **B** (17,88l / 1,989) | litres+price both wrong | litres **17,88✓** / price 1,909≈ | litres **17,88✓** / price **1,989✓** | litres✗missing / price≈1989 (garbled) | litres 17,B8≈(1 char) / price **1,989✓** |
| **C** (18,86l / 2,129) | litres wrong, price✗missing | litres **18,86✓** / price✗missing | litres **0.86✗** (broke it!) / price≈2,129 | litres **18,86✓** / price✗missing | litres **18,86✓** / price≈2,129 |
Note step 2 (Levels) actively *breaks* C's litres reading that was fine
after step 1 alone — a clear example of a step helping one receipt while
hurting another, which is why no single step is a safe generalization on
its own even though each one looks promising in isolation.
## Root causes / methodology notes
1. **GIMP's "High Pass" filter has no PDB/Script-Fu wrapper** — confirmed
by querying the full procedure database (`gimp-procedural-db-query`)
for anything matching `high.?pass`; only `plug-in-edge`,
`plug-in-unsharp-mask`, `plug-in-sharpen`, and `gimp-selection-sharpen`
exist. It's GEGL-only in this GIMP version (2.10.30). The manual
Gaussian-Blur-with-Grain-Extract-blend technique is the only
automatable equivalent.
2. **GIMP's own "default" vs "legacy" grain-extract blend-mode variants
turned out not to matter** — tested both (`LAYER-MODE-GRAIN-EXTRACT`
= 46 and `LAYER-MODE-GRAIN-EXTRACT-LEGACY` = 20 against the same
input): numerically near-identical output either way.
3. **A real, unresolved gap remains between GIMP's GUI-applied
Gaussian-Blur-with-in-dialog-blend and a classic two-layer Script-Fu
reproduction** (duplicate layer → blur → `gimp-layer-set-mode` →
flatten) of the *same nominal parameters*. The GUI version's grain-
extract output has roughly double the standard deviation (spread) of
the scripted version's, confirmed via direct pixel-value percentile
comparison (user's real `_5_1.jpg`: mean 118.5, std 40.4; scripted
equivalent: mean 128.0, std 8.7–23.2 across a wide blur-radius sweep
from 8 to 180 — never converges, ruling out a simple radius/scale
miscalibration). `plug-in-gauss`'s legacy-compat wrapper applies an
internal ~0.32× multiplier converting its "radius" input to GEGL's
`std-dev`, and the Grain Extract formula itself was confirmed correct
from GEGL source (`CLAMP(lower - upper + 0.5)` in normalized space).
The actual cause (most likely GEGL's abyss-policy/edge-handling or an
image-precision difference) was not pinned down — **GIMP batch
scripting cannot currently reproduce this step's real-world output
closely enough to trust for pipeline automation.**
4. Separately (fixed along the way, not the cause of the above): GIMP
batch-mode has two sharp edges worth remembering if scripted again —
(a) `gimp-console -b <script> -b '(gimp-quit 0)'` as two separate
`-b` flags does not reliably terminate the process on error or even
on success; always pipe one script via stdin (`-b -`) with
`(gimp-quit 0)` as the last form *inside* that same stream.
(b) `gimp-image-insert-layer`'s position argument needs an explicit
`0` (top of stack) — `-1` does not reliably mean "top" and silently
produces a no-op-looking flatten (new layer ends up under the base
layer, which then opaquely covers it).
## Native Python reimplementation and full-batch result — rejected
Reimplemented the two steps natively (no GIMP dependency) in
`tooling-container/src/preprocess.py`, inserted before the existing
bilateral+autocontrast stage:
- `highpass_substitute()`: `cv2.GaussianBlur` + the confirmed
`clip(gray - blurred + 128)` grain-extract formula + a linear
levels-stretch (low=58, high=135, gamma=0.96 — the same values as the
GIMP recipe).
- `edge_enhance()`: a calibrated unsharp-mask-style boost
(`gray + 0.69 * (gray - GaussianBlur(gray, sigma=0.52))`) as the
OpenCV substitute for GIMP's NL Filter edge-enhancement mode (no direct
OpenCV equivalent exists).
- Both steps' blur radii scale with image width (`/ 768`, the width A/B
were shot at) — necessary because C is 4096×3072, roughly 4-5x A/B's
linear resolution, and a fixed pixel radius calibrated at one scale
clearly under/over-shoots at the other (confirmed: before scaling, C's
litres read `0.86` instead of `18,86`; after scaling, `18,86✓`).
On the A/B/C set, this **outperformed the GIMP-based matrix** — litres
exact on all three images, price exact-or-close on all three (no GIMP
dependency, no reproduction-gap uncertainty).
**But it failed full-batch validation** against the real 52-receipt
ground-truth set (`testdata/2026_benzin_kosten/`), the required next step
per this project's evidence-based philosophy before touching the real
pipeline:
| Metric | Baseline (before) | With new preprocessing | Change |
|---|---|---|---|
| Date-matched | 37/52 | 30/52 | **-7** |
| price_per_litre | 27/37 (73%) | 18/30 (60%) | ↓ |
| litres (the targeted field) | 19/37 (51%) | 12/30 (40%) | ↓ worse |
| discount | 29/37 (78%) | 22/30 (73%) | ↓ |
| total_price | 23/37 (62%) | 18/30 (60%) | ↓ |
Regression across every metric, including the exact field the change
targeted — the same failure pattern as the earlier-rejected unsharp-
masking attempt (see `AGENT.md`): it visibly helps the specific hard
receipts it was designed for, but amplifies the dotted security-
background pattern into OCR noise on receipts that were already reading
fine, making the batch worse overall. **Reverted** from
`preprocess.py`/the numbered scripts/docs; not going into the pipeline.
## Adaptive per-receipt escalation — accepted
This is now the second distinct sharpening-family approach (after naive
unsharp masking - see `AGENT.md`) confirmed to fail *when applied
globally*: strong locally on hard receipts, net-negative on the full
batch, because it amplifies the security background pattern into noise
on receipts the cheap baseline already reads fine. Both failures point
at the same fix: apply it *selectively*, only to receipts the cheap pass
already reads poorly, rather than to every photo.
Implemented as `tooling-container/src/preprocess.py`'s four variants
(0=baseline, 1=+`highpass_substitute`, 2=+`edge_enhance`, 3=+both) tried
cheapest-first by `tooling-container/src/adaptive.py`, escalating only
when the current variant's raw OCR text fails a cheap arithmetic-
consistency check (`tooling-container/src/plausibility.py`:
`price_per_litre * litres - discount ≈ total_price` within 0.05 EUR —
the same identity and tolerance `android/.../ReceiptPlausibility.kt` and
`compare_ground_truth.py` already use, applied here via regex-extracted
candidate numbers straight from OCR text so the check needs no LLM call
and runs once per variant instead of once per receipt). This keeps the
expensive LLM structuring call to exactly one per receipt regardless of
how many variants are tried, since tier selection happens entirely
before that call.
Full-batch result against `testdata/2026_benzin_kosten/` (same baseline
as above):
| Metric | Baseline | Adaptive | Change |
|---|---|---|---|
| Date-matched | 37/52 | 40/52 | **+3** |
| price_per_litre | 27/37 (73%) | 34/40 (85%) | **↑** |
| litres | 19/37 (51%) | 24/40 (60%) | **↑** |
| discount | 29/37 (78%) | 34/40 (85%) | **↑** |
| total_price | 23/37 (62%) | 24/40 (60%) | ≈ flat (+1 in count) |
Improves every metric except total_price, which is essentially flat.
**Accepted** — wired into `server.py`, `test_batch.sh`, `test_single.sh`,
and the new `12_adaptive_preprocess_ocr.sh` (replacing the separate
`1_preprocess_image.sh` + `2_ocr_image.sh` calls those two use;
`1_preprocess_image.sh`/`2_ocr_image.sh` themselves are unchanged and
still useful for inspecting one variant in isolation).
## Conclusion
The litres/price-per-litre line was the pipeline's hardest OCR target
specifically because every fix tried so far has been a global tradeoff:
good for hard receipts, bad for easy ones, on the same underlying cause
(amplifying the security background pattern). Making the fix conditional
on the cheap pass already looking wrong - judged cheaply, without an
LLM call - was what actually let the hard-receipt win through without
the easy-receipt cost.
+4 -1
View File
@@ -11,7 +11,10 @@ RUN pip install --no-cache-dir \
pillow==11.0.0 \
pytesseract==0.3.13 \
requests==2.32.3 \
odfpy==1.4.1
odfpy==1.4.1 \
fastapi==0.115.5 \
uvicorn[standard]==0.32.1 \
python-multipart==0.0.17
COPY src/ /opt/app/src/
RUN chmod -R a+rX /opt/app/src
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Adaptive preprocessing: tries preprocess.py's variants (0=cheapest,
up to VARIANT_COUNT-1=most elaborate) in order, OCRing each and scoring
the result with plausibility.best_residual() - no LLM call - stopping at
the first variant whose OCR text already looks receipt-consistent.
Exists because always applying the heavier variants regressed the full
test batch even though they clearly help the specific hard receipts they
were designed for (see
testing/2026_10_04_gimp_highpass_nlfilter_experiment.md): escalating only
when the cheap variant already looks implausible avoids that cost - and
that risk - on the majority of receipts the baseline already reads
correctly, while keeping the expensive LLM structuring call to exactly
one per receipt no matter how many variants are tried.
"""
import argparse
import os
from ocr import ocr as run_ocr
from plausibility import best_residual, is_plausible
from preprocess import VARIANT_COUNT, preprocess
def preprocess_and_ocr_adaptive(image_path: str, out_dir: str) -> dict:
"""Returns {"ocr_text", "final_image_path", "variant", "residual",
"variants_tried"} - "variant" is whichever one was kept (the first
plausible one, or else the one with the lowest residual), not
necessarily the last one tried."""
best = None # (residual, ocr_text, final_path, variant)
variants_tried = 0
for variant in range(VARIANT_COUNT):
variant_dir = os.path.join(out_dir, f"variant{variant}")
final_path = preprocess(image_path, variant_dir, variant=variant)
ocr_txt_path = run_ocr(final_path, variant_dir)
with open(ocr_txt_path, "r", encoding="utf-8") as f:
ocr_text = f.read()
variants_tried += 1
residual = best_residual(ocr_text)
if best is None or residual < best[0]:
best = (residual, ocr_text, final_path, variant)
if is_plausible(residual):
break
residual, ocr_text, final_path, variant = best
return {
"ocr_text": ocr_text,
"final_image_path": final_path,
"variant": variant,
"residual": residual,
"variants_tried": variants_tried,
}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("-in", dest="in_path", required=True)
parser.add_argument("-out", dest="out_dir", required=True)
args = parser.parse_args()
result = preprocess_and_ocr_adaptive(args.in_path, args.out_dir)
ocr_path = os.path.join(args.out_dir, "ocr.txt")
with open(ocr_path, "w", encoding="utf-8") as f:
f.write(result["ocr_text"])
print(
f"variant={result['variant']} "
f"variants_tried={result['variants_tried']} "
f"residual={result['residual']:.3f} "
f"final_image={result['final_image_path']}"
)
print(ocr_path)
if __name__ == "__main__":
main()
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Cheap, LLM-free plausibility scoring of raw OCR text from a German fuel
receipt. Used by adaptive.py to rank several preprocessing variants' OCR
output against each other - and decide whether to bother trying a more
elaborate variant at all - without paying for an LLM call per variant.
Mirrors the same arithmetic identity and EUR tolerance
android/.../ReceiptPlausibility.kt and compare_ground_truth.py use
(total_price == price_per_litre * litres - discount, within 0.05 EUR),
applied here to candidate numbers found directly in raw OCR text rather
than already-structured fields, since this runs before (and to help pick
input for) the single structuring LLM call.
"""
import re
TOLERANCE_EUR = 0.05
_NUMBER_RE = re.compile(r"(\d{1,3}),(\d{2,3})")
# Plausible-value ranges for German fuel receipts, used to keep the
# combination search small and to reject obviously-wrong candidates
# (transaction IDs, card digits, etc. pulled in by the same regex).
_PRICE_RANGE = (0.5, 3.5)
_LITRES_RANGE = (1.0, 150.0)
_TOTAL_RANGE = (1.0, 500.0)
_DISCOUNT_RANGE = (0.0, 50.0)
def _candidates(ocr_text: str) -> list[float]:
out = []
for m in _NUMBER_RE.finditer(ocr_text):
try:
out.append(float(f"{m.group(1)}.{m.group(2)}"))
except ValueError:
continue
return out
def best_residual(ocr_text: str) -> float:
"""Smallest |price_per_litre * litres - discount - total_price| over
plausible-range combinations of numbers found in ocr_text. Lower is
more plausible; see is_plausible() for the pass/fail threshold. inf
if the text doesn't contain enough candidate numbers in the right
ranges to try any combination at all."""
candidates = _candidates(ocr_text)
prices = [c for c in candidates if _PRICE_RANGE[0] <= c <= _PRICE_RANGE[1]]
litres = [c for c in candidates if _LITRES_RANGE[0] <= c <= _LITRES_RANGE[1]]
totals = [c for c in candidates if _TOTAL_RANGE[0] <= c <= _TOTAL_RANGE[1]]
discounts = [0.0] + [
c for c in candidates if _DISCOUNT_RANGE[0] < c <= _DISCOUNT_RANGE[1]
]
best = float("inf")
for p in prices:
for litre in litres:
if litre == p:
continue
price_times_litres = p * litre
for t in totals:
if t == p or t == litre:
continue
for d in discounts:
residual = abs(price_times_litres - d - t)
if residual < best:
best = residual
return best
def is_plausible(residual: float) -> bool:
return residual <= TOLERANCE_EUR
+72 -11
View File
@@ -8,12 +8,22 @@ the output directory (01_gray, 02_bilateral, 03_final) so the effect of
each step can be inspected on its own before trusting the final image to
OCR.
Heavier processing (strong denoising, local/CLAHE contrast, unsharp
Heavier processing (strong denoising, local/CLAHE contrast, naive unsharp
masking) was tried and tested worse: each one amplifies the security
background pattern into OCR noise rather than suppressing it. This
lighter two-step version was chosen because, across a set of receipts
that the plain-grayscale baseline OCR'd incorrectly, it recovered data
the baseline missed and never made a previously-correct read worse.
lighter two-step version (variant 0 below, and the default) was chosen
because, across a set of receipts that the plain-grayscale baseline
OCR'd incorrectly, it recovered data the baseline missed and never made
a previously-correct read worse.
Variants 1-3 add a high-pass-style detail boost and/or edge enhancement
before this same bilateral+autocontrast stage. Applied unconditionally
to every receipt, this combination also tested worse on the full batch
for the same reason (see
testing/2026_10_04_gimp_highpass_nlfilter_experiment.md) - but it
noticeably helps the specific hard receipts it was designed for, so
adaptive.py tries these variants only when the cheap variant 0 already
looks implausible, rather than applying them globally.
"""
import argparse
import os
@@ -22,6 +32,13 @@ import cv2
import numpy as np
from PIL import Image, ImageOps
# Width the variant 1-3 parameters below were calibrated at; their blur
# radii scale relative to this so they hold up across this project's test
# photos, which range from ~768px to ~4096px wide.
REFERENCE_WIDTH = 768
VARIANT_COUNT = 4 # 0=baseline, 1=+highpass, 2=+edge-enhance, 3=+both
def load_grayscale(path: str) -> np.ndarray:
img = Image.open(path)
@@ -29,23 +46,66 @@ def load_grayscale(path: str) -> np.ndarray:
return np.array(img)
def highpass_substitute(
gray: np.ndarray, blur_sigma=4.15, low_in=58, high_in=135, gamma=0.96
) -> np.ndarray:
scale = gray.shape[1] / REFERENCE_WIDTH
blurred = cv2.GaussianBlur(gray, (0, 0), sigmaX=blur_sigma * scale)
grain = np.clip(gray.astype(np.float32) - blurred.astype(np.float32) + 128.0, 0, 255)
normalized = np.clip((grain - low_in) / (high_in - low_in), 0, 1)
gamma_applied = np.power(normalized, 1.0 / gamma)
return np.clip(gamma_applied * 255.0, 0, 255).astype(np.uint8)
def edge_enhance(gray: np.ndarray, amount=0.69, sigma=0.52) -> np.ndarray:
scale = gray.shape[1] / REFERENCE_WIDTH
blurred = cv2.GaussianBlur(gray, (0, 0), sigmaX=sigma * scale)
sharpened = gray.astype(np.float32) + amount * (
gray.astype(np.float32) - blurred.astype(np.float32)
)
return np.clip(sharpened, 0, 255).astype(np.uint8)
def autocontrast(gray: np.ndarray, low_pct=2, high_pct=98) -> np.ndarray:
lo, hi = np.percentile(gray, (low_pct, high_pct))
stretched = (gray.astype(np.float32) - lo) * 255.0 / (hi - lo)
return np.clip(stretched, 0, 255).astype(np.uint8)
def preprocess(path: str, out_dir: str) -> str:
def preprocess(path: str, out_dir: str, variant: int = 0) -> str:
"""variant: 0=baseline (bilateral+autocontrast only, unchanged default
behavior), 1=+highpass_substitute, 2=+edge_enhance, 3=+both. See
adaptive.py for why this is a parameter rather than always-on."""
if not 0 <= variant < VARIANT_COUNT:
raise ValueError(f"variant must be 0-{VARIANT_COUNT - 1}, got {variant}")
os.makedirs(out_dir, exist_ok=True)
gray = load_grayscale(path)
cv2.imwrite(os.path.join(out_dir, "01_gray.png"), gray)
stage = load_grayscale(path)
cv2.imwrite(os.path.join(out_dir, "01_gray.png"), stage)
bilateral = cv2.bilateralFilter(gray, d=5, sigmaColor=50, sigmaSpace=50)
cv2.imwrite(os.path.join(out_dir, "02_bilateral.png"), bilateral)
# Stage filenames only extend beyond the original 01/02/03 numbering
# for variants 1-3, which adaptive.py always runs in their own
# per-variant subdirectory - so this never changes what
# 1_preprocess_image.sh's default (variant 0) manual single-stage
# inspection produces, or any doc/script that hardcodes 03_final.png.
next_n = 2
if variant in (1, 3):
stage = highpass_substitute(stage)
cv2.imwrite(os.path.join(out_dir, f"0{next_n}_highpass.png"), stage)
next_n += 1
if variant in (2, 3):
stage = edge_enhance(stage)
cv2.imwrite(os.path.join(out_dir, f"0{next_n}_edgeenhance.png"), stage)
next_n += 1
bilateral = cv2.bilateralFilter(stage, d=5, sigmaColor=50, sigmaSpace=50)
cv2.imwrite(os.path.join(out_dir, f"0{next_n}_bilateral.png"), bilateral)
next_n += 1
final = autocontrast(bilateral)
final_path = os.path.join(out_dir, "03_final.png")
final_path = os.path.join(out_dir, f"0{next_n}_final.png")
cv2.imwrite(final_path, final)
return final_path
@@ -55,9 +115,10 @@ def main():
parser = argparse.ArgumentParser()
parser.add_argument("-in", dest="in_path", required=True)
parser.add_argument("-out", dest="out_dir", required=True)
parser.add_argument("-variant", dest="variant", type=int, default=0)
args = parser.parse_args()
final_path = preprocess(args.in_path, args.out_dir)
final_path = preprocess(args.in_path, args.out_dir, args.variant)
print(final_path)
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""HTTP API that serves the pipeline (preprocess -> ocr -> structure) to
the Android app. Runs the same in-process functions the numbered host
scripts drive via `docker run` per stage, but skips the per-stage
container start-up since a live server has no need for that
inspectability - the host scripts remain the way to inspect each stage
independently during pipeline development.
Jobs are processed asynchronously so the app can show real per-stage
progress: POST /jobs returns immediately with a job id; GET /jobs/{id}
reports which of preprocessing/ocr/extracting is currently running, and
the final result once done. Job state lives in memory only - this is a
personal, LAN-only, single-instance server with no need for persistence
here (the Android app is the system of record for confirmed receipts).
"""
import os
import shutil
import tempfile
import threading
import time
import uuid
import requests
from fastapi import FastAPI, HTTPException, UploadFile
from fastapi.responses import JSONResponse
from adaptive import preprocess_and_ocr_adaptive
from extract import structure
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:3b")
app = FastAPI(title="fuelish")
_jobs: dict[str, dict] = {}
_jobs_lock = threading.Lock()
def _set_job(job_id: str, **fields) -> None:
with _jobs_lock:
_jobs[job_id].update(fields)
def _ensure_model_pulled() -> None:
"""Best-effort: wait for Ollama to come up (compose starts `api` and
`ollama` together, with no ordering guarantee beyond container start)
and pull OLLAMA_MODEL if it isn't already present, so a bare `docker
compose up` is enough without a manual `ollama pull` step. Runs in a
background thread - jobs submitted before this finishes will simply
fail with a clear error from Ollama."""
tags = None
for _ in range(60):
try:
tags = requests.get(f"{OLLAMA_BASE_URL}/api/tags", timeout=5).json()
break
except requests.RequestException:
time.sleep(2)
if tags is None:
return
have = {m["name"] for m in tags.get("models", [])}
if OLLAMA_MODEL in have or any(m.startswith(OLLAMA_MODEL + ":") for m in have):
return
try:
requests.post(
f"{OLLAMA_BASE_URL}/api/pull",
json={"name": OLLAMA_MODEL},
timeout=1800,
)
except requests.RequestException:
pass
@app.on_event("startup")
def _on_startup() -> None:
threading.Thread(target=_ensure_model_pulled, daemon=True).start()
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
def _process_job(job_id: str, image_path: str, scratch_dir: str) -> None:
try:
_set_job(job_id, status="preprocessing")
adaptive_result = preprocess_and_ocr_adaptive(image_path, scratch_dir)
_set_job(job_id, status="extracting")
result = structure(adaptive_result["ocr_text"])
_set_job(job_id, status="done", result=result)
except Exception as exc: # noqa: BLE001 - reported to the client, not swallowed
_set_job(job_id, status="error", error=str(exc))
finally:
shutil.rmtree(scratch_dir, ignore_errors=True)
@app.post("/jobs", status_code=201)
async def create_job(image: UploadFile) -> JSONResponse:
job_id = uuid.uuid4().hex
scratch_dir = tempfile.mkdtemp(prefix=f"fuelish-{job_id}-")
image_path = os.path.join(scratch_dir, "upload" + os.path.splitext(image.filename or "")[1])
with open(image_path, "wb") as f:
f.write(await image.read())
with _jobs_lock:
_jobs[job_id] = {"status": "queued", "result": None, "error": None, "_created": time.time()}
thread = threading.Thread(target=_process_job, args=(job_id, image_path, scratch_dir), daemon=True)
thread.start()
return JSONResponse({"job_id": job_id}, status_code=201)
@app.get("/jobs/{job_id}")
def get_job(job_id: str) -> dict:
with _jobs_lock:
job = _jobs.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="job not found")
return {"status": job["status"], "result": job["result"], "error": job["error"]}
Regular → Executable
+23 -11
View File
@@ -1,8 +1,10 @@
#!/usr/bin/env bash
# Pushes the gastronomy tools image - already built locally with
# ./build-image.sh, and ideally verified with the numbered pipeline
# scripts - to a container registry, so the k3s cluster (or other
# machines) can pull it without rebuilding it.
# Pushes every image ./build-image.sh built locally - the fuelish tools
# image and the Android CI/release build image, each already verified
# (tools image with the numbered pipeline scripts, android-builder image
# with ./run-image.sh) - to a container registry, so the k3s cluster can
# pull the tools image and Gitea Actions can pull the android-builder
# image without rebuilding either.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
@@ -21,21 +23,31 @@ source "$ROOT/registry.env"
: "${REGISTRY:?registry.env must set REGISTRY}"
: "${REGISTRY_IMAGE:?registry.env must set REGISTRY_IMAGE}"
: "${REGISTRY_ANDROID_IMAGE:?registry.env must set REGISTRY_ANDROID_IMAGE}"
: "${REGISTRY_USER:?registry.env must set REGISTRY_USER}"
: "${REGISTRY_PASSWORD:?registry.env must set REGISTRY_PASSWORD}"
VERSION="$(<"$ROOT/tooling-container/VERSION")"
[ -n "$VERSION" ] || fail "tooling-container/VERSION is empty"
TOOLS_VERSION="$(<"$ROOT/tooling-container/VERSION")"
[ -n "$TOOLS_VERSION" ] || fail "tooling-container/VERSION is empty"
docker image inspect "$REGISTRY_IMAGE:$VERSION" >/dev/null 2>&1 \
|| fail "$REGISTRY_IMAGE:$VERSION not found locally - run ./build-image.sh first"
ANDROID_VERSION="$(<"$ROOT/build-image/VERSION")"
[ -n "$ANDROID_VERSION" ] || fail "build-image/VERSION is empty"
docker image inspect "$REGISTRY_IMAGE:$TOOLS_VERSION" >/dev/null 2>&1 \
|| fail "$REGISTRY_IMAGE:$TOOLS_VERSION not found locally - run ./build-image.sh first"
docker image inspect "$REGISTRY_ANDROID_IMAGE:$ANDROID_VERSION" >/dev/null 2>&1 \
|| fail "$REGISTRY_ANDROID_IMAGE:$ANDROID_VERSION not found locally - run ./build-image.sh first"
echo "== Logging in to $REGISTRY =="
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
echo "== Pushing $REGISTRY_IMAGE:$VERSION and :latest =="
docker push "$REGISTRY_IMAGE:$VERSION"
echo "== Pushing $REGISTRY_IMAGE:$TOOLS_VERSION and :latest =="
docker push "$REGISTRY_IMAGE:$TOOLS_VERSION"
docker push "$REGISTRY_IMAGE:latest"
echo "== Pushing $REGISTRY_ANDROID_IMAGE:$ANDROID_VERSION and :latest =="
docker push "$REGISTRY_ANDROID_IMAGE:$ANDROID_VERSION"
docker push "$REGISTRY_ANDROID_IMAGE:latest"
echo "== Done =="
echo "Image: $REGISTRY_IMAGE:$VERSION"
echo "Images: $REGISTRY_IMAGE:$TOOLS_VERSION, $REGISTRY_ANDROID_IMAGE:$ANDROID_VERSION"