Compare commits
11 Commits
f0ec353d3b
..
v0.5.5
| Author | SHA1 | Date | |
|---|---|---|---|
| ae2c9be564 | |||
| 8980f9599b | |||
| 7a91a37049 | |||
| ca85530b5a | |||
| 9bb1844cf4 | |||
| 68e0cd8967 | |||
| ad2ef1689e | |||
| d5f4381169 | |||
| 516f5d3b7d | |||
| 7cea285ec3 | |||
| 7678d28717 |
@@ -0,0 +1,7 @@
|
||||
# Copy to .env (gitignored, never commit the real value) to require an
|
||||
# API key on the server's /jobs endpoint - see docker-compose.yml and
|
||||
# tooling-container/src/server.py. Leave FUELISH_API_KEY unset/empty for
|
||||
# the original LAN-only/no-auth setup.
|
||||
#
|
||||
# Generate one with: openssl rand -hex 32
|
||||
FUELISH_API_KEY=
|
||||
@@ -0,0 +1,83 @@
|
||||
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
|
||||
printf '%s\n' '${{ secrets.DL_SFTP_KEY }}' > ~/.ssh/dl_sftp_key
|
||||
chmod 600 ~/.ssh/dl_sftp_key
|
||||
# Sanity-check the key is actually loadable before attempting the
|
||||
# SFTP connection - fails fast with a clear message instead of
|
||||
# SFTP's much less obvious "Permission denied" when the key
|
||||
# material itself is malformed (e.g. mangled by how the secret
|
||||
# was entered into Gitea - see DL_SFTP_KEY in README.md).
|
||||
ssh-keygen -y -f ~/.ssh/dl_sftp_key > /dev/null \
|
||||
|| { echo "FAIL: ~/.ssh/dl_sftp_key is not a valid private key - re-check the DL_SFTP_KEY secret" >&2; exit 1; }
|
||||
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
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# Test receipt images / ground-truth spreadsheets (not checked in)
|
||||
/testdata/
|
||||
|
||||
# Local registry credentials (see registry.env.example)
|
||||
/registry.env
|
||||
|
||||
# Local API key for docker-compose.yml's api service (see .env.example)
|
||||
/.env
|
||||
|
||||
# Local Claude Code settings
|
||||
/.claude/settings.local.json
|
||||
|
||||
# Generated pipeline output (see test_batch.sh, 1_preprocess_image.sh,
|
||||
# 2_ocr_image.sh, 3_extract_data.sh) - intermediate/result dirs are
|
||||
# created as siblings of whatever -in directory was processed
|
||||
*-scratch/
|
||||
*-result/
|
||||
|
||||
# Python bytecode cache (picked up if src/ is ever imported on the host
|
||||
# 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
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Step 0: start the Ollama inference server that the pipeline's
|
||||
# structuring step (3_extract_data.sh) talks to. Runs the official
|
||||
# ollama/ollama image as a long-lived container with model weights kept
|
||||
# in a named Docker volume - deliberately separate from the
|
||||
# tooling-container (which is stateless and one-shot per receipt), so the
|
||||
# model stays loaded in memory between pipeline runs instead of being
|
||||
# reloaded on every receipt.
|
||||
#
|
||||
# Usage: ./0_run_ollama.sh [-model <name>]
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
command -v docker >/dev/null 2>&1 || fail "docker not found in PATH"
|
||||
|
||||
MODEL="qwen2.5:3b"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-model) MODEL="${2:-}"; shift 2 ;;
|
||||
*) fail "unknown argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
CONTAINER=fuelish-ollama
|
||||
VOLUME=fuelish-ollama-data
|
||||
PORT=11434
|
||||
|
||||
if docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
|
||||
echo "== $CONTAINER already running =="
|
||||
elif docker ps -a --format '{{.Names}}' | grep -qx "$CONTAINER"; then
|
||||
echo "== Starting existing $CONTAINER container =="
|
||||
docker start "$CONTAINER" >/dev/null
|
||||
else
|
||||
echo "== Creating $CONTAINER (model weights kept in volume $VOLUME) =="
|
||||
docker run -d \
|
||||
--name "$CONTAINER" \
|
||||
-p "127.0.0.1:$PORT:11434" \
|
||||
-v "$VOLUME:/root/.ollama" \
|
||||
--restart unless-stopped \
|
||||
ollama/ollama >/dev/null
|
||||
fi
|
||||
|
||||
echo "== Waiting for Ollama to become ready =="
|
||||
ready=0
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -s --max-time 2 "http://127.0.0.1:$PORT/api/tags" >/dev/null 2>&1; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
[ "$ready" = "1" ] || fail "Ollama did not become ready on port $PORT"
|
||||
|
||||
if ! docker exec "$CONTAINER" ollama list | awk 'NR>1{print $1}' | grep -qx "$MODEL"; then
|
||||
echo "== Pulling model $MODEL =="
|
||||
docker exec "$CONTAINER" ollama pull "$MODEL"
|
||||
fi
|
||||
|
||||
echo "== Done =="
|
||||
echo "Ollama running at http://127.0.0.1:$PORT (model: $MODEL)"
|
||||
echo "Next: ./1_preprocess_image.sh -in <photo> -out <scratch-dir>"
|
||||
Executable
+70
@@ -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"
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# Step 1 of the OCR pipeline: mild smoothing + contrast-stretch a receipt
|
||||
# photo. Writes each intermediate stage (01_gray, 02_bilateral, 03_final)
|
||||
# into the output directory so every step can be inspected independently
|
||||
# before trusting the final image to OCR.
|
||||
#
|
||||
# Usage: ./1_preprocess_image.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")"
|
||||
|
||||
# On a vboxsf-mounted repo, files are owned by root:vboxsf with no
|
||||
# "other" access - join the mounted dirs' groups so --user can read/write
|
||||
# them without needing root inside the container.
|
||||
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 "== Preprocessing $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/preprocess.py -in "/data/in/$IN_FILE" -out /data/out
|
||||
|
||||
echo "== Done =="
|
||||
echo "Stages written to: $OUT_ABS/{01_gray,02_bilateral,03_final}.png"
|
||||
echo "Next: ./2_ocr_image.sh -in $OUT_ABS/03_final.png -out $OUT_ABS"
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Step 2 of the pipeline: run Tesseract OCR (German) over a preprocessed
|
||||
# receipt image, writing the raw recognized text to the output directory
|
||||
# so it can be checked before it is handed to the AI structuring step.
|
||||
#
|
||||
# Usage: ./2_ocr_image.sh -in <preprocessed-image> -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 <preprocessed-image> -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")"
|
||||
|
||||
# On a vboxsf-mounted repo, files are owned by root:vboxsf with no
|
||||
# "other" access - join the mounted dirs' groups so --user can read/write
|
||||
# them without needing root inside the container.
|
||||
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 "== OCR-ing $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/ocr.py -in "/data/in/$IN_FILE" -out /data/out
|
||||
|
||||
echo "== Done =="
|
||||
echo "OCR text written to: $OUT_ABS/ocr.txt"
|
||||
echo "Next: ./3_extract_data.sh -in $OUT_ABS/ocr.txt -out $OUT_ABS"
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# Step 3 of the pipeline: send raw OCR text to a small text-only LLM
|
||||
# (served by Ollama on the host) to structure it into the receipt JSON
|
||||
# schema. Runs with --network host so the container can reach Ollama at
|
||||
# 127.0.0.1 on the host; override with OLLAMA_URL / OLLAMA_MODEL env vars
|
||||
# if Ollama runs elsewhere or you want a different model.
|
||||
#
|
||||
# Usage: ./3_extract_data.sh -in <ocr.txt> -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 <ocr.txt> -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")"
|
||||
|
||||
# On a vboxsf-mounted repo, files are owned by root:vboxsf with no
|
||||
# "other" access - join the mounted dirs' groups so --user can read/write
|
||||
# them without needing root inside the container.
|
||||
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 "== Extracting structured data from $IN_FILE =="
|
||||
docker run --rm \
|
||||
--network host \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
"${GROUP_ADD_ARGS[@]}" \
|
||||
-e OLLAMA_URL="${OLLAMA_URL:-http://127.0.0.1:11434/api/chat}" \
|
||||
-e OLLAMA_MODEL="${OLLAMA_MODEL:-qwen2.5:3b}" \
|
||||
-v "$IN_DIR:/data/in:ro" \
|
||||
-v "$OUT_ABS:/data/out" \
|
||||
"$IMAGE" \
|
||||
python3 /opt/app/src/extract.py -in "/data/in/$IN_FILE" -out /data/out
|
||||
|
||||
echo "== Done =="
|
||||
echo "Extracted JSON written to: $OUT_ABS/extracted.json"
|
||||
@@ -0,0 +1,125 @@
|
||||
# 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`; `OLLAMA_NUM_CTX` (default 3072) and `OLLAMA_NUM_THREAD`
|
||||
(default: Ollama's) set the request options).
|
||||
- `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).
|
||||
@@ -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
|
||||
@@ -1,3 +1,178 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
By default this has no auth (fine for a LAN-only setup - see the app's
|
||||
`network_security_config.xml` for the HTTP-only/private-IP-range
|
||||
restriction this implies). For a deployment reachable outside a trusted
|
||||
LAN, copy `.env.example` to `.env`, set `FUELISH_API_KEY` (e.g.
|
||||
`openssl rand -hex 32`), and enter the same key in the Android app's
|
||||
Settings tab alongside the server URL - every request then needs a
|
||||
matching `X-API-Key` header (see `tooling-container/src/server.py`).
|
||||
This only checks the key; it doesn't add TLS - put a reverse proxy in
|
||||
front of the server for anything public.
|
||||
|
||||
Then, in the Android app's Settings tab, point it at
|
||||
`http://<this-host>:8080`. Take a photo, watch the 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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Vendored
+9
@@ -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(...);
|
||||
}
|
||||
@@ -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,94 @@
|
||||
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, apiKey: 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)
|
||||
.withApiKey(apiKey).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, apiKey: String, jobId: String): JobStatusResponse = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs/$jobId").get()
|
||||
.withApiKey(apiKey).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
|
||||
}
|
||||
|
||||
/** Matches server.py's require_api_key(): omits the header entirely for
|
||||
* a blank key rather than sending an empty one, so a server with no
|
||||
* FUELISH_API_KEY configured behaves identically either way. */
|
||||
private fun Request.Builder.withApiKey(apiKey: String): Request.Builder =
|
||||
if (apiKey.isBlank()) this else header("X-API-Key", apiKey)
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.ladkau.fuelish.data
|
||||
|
||||
/**
|
||||
* Everything the app needs that isn't a saved receipt: the fuelish
|
||||
* server's base URL (e.g. "http://192.168.1.10:8080") and, if the server
|
||||
* has FUELISH_API_KEY set (see tooling-container/src/server.py), the
|
||||
* matching key to send as X-API-Key. Blank apiKey is fine against a
|
||||
* server with no key configured - that's the original no-auth setup.
|
||||
* Persisted via Preferences DataStore, see [ConfigRepository].
|
||||
*/
|
||||
data class AppConfig(
|
||||
val serverUrl: String = "",
|
||||
val apiKey: String = "",
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
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 API_KEY = stringPreferencesKey("api_key")
|
||||
}
|
||||
|
||||
val config: Flow<AppConfig> = context.configDataStore.data.map { prefs ->
|
||||
AppConfig(
|
||||
serverUrl = prefs[Keys.SERVER_URL] ?: "",
|
||||
apiKey = prefs[Keys.API_KEY] ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun save(config: AppConfig) {
|
||||
context.configDataStore.edit { prefs ->
|
||||
prefs[Keys.SERVER_URL] = config.serverUrl
|
||||
prefs[Keys.API_KEY] = config.apiKey
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,29 @@
|
||||
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, apiKey: String, imageFile: File): String =
|
||||
api.submitImage(baseUrl, apiKey, imageFile)
|
||||
|
||||
suspend fun pollJob(baseUrl: String, apiKey: String, jobId: String): JobStatusResponse =
|
||||
api.getJobStatus(baseUrl, apiKey, 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
@@ -0,0 +1,68 @@
|
||||
package de.ladkau.fuelish.export
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import de.ladkau.fuelish.data.Receipt
|
||||
import java.io.OutputStream
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
/** IMG_YYYYMMDD_HHMMSS.jpg, built from the receipt's own extracted
|
||||
* date/time (Receipt.date/time - DD.MM.YYYY / HH:MM:SS), not the photo
|
||||
* file's own capture timestamp or when it was saved in the app - so the
|
||||
* exported name reflects what's on the receipt. Null if date or time is
|
||||
* missing or doesn't parse, since there's then no valid name to build. */
|
||||
fun Receipt.photoExportName(): String? {
|
||||
val d = date ?: return null
|
||||
val t = time ?: return null
|
||||
val parsed = try {
|
||||
SOURCE_FORMAT.parse("$d $t")
|
||||
} catch (e: ParseException) {
|
||||
null
|
||||
} ?: return null
|
||||
return "IMG_${TARGET_FORMAT.format(parsed)}.jpg"
|
||||
}
|
||||
|
||||
private val SOURCE_FORMAT = SimpleDateFormat("dd.MM.yyyy HH:mm:ss", Locale.GERMANY)
|
||||
private val TARGET_FORMAT = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
|
||||
|
||||
/** Zips every receipt's photo (skipping receipts with no photo, or no
|
||||
* parseable date/time to name it with) under photoExportName(), appending
|
||||
* "_2", "_3", ... on a name collision (e.g. two receipts logged with the
|
||||
* same date and time to the second) so nothing silently overwrites
|
||||
* another entry. Returns the count actually written. */
|
||||
fun exportPhotos(receipts: List<Receipt>, resolver: ContentResolver, out: OutputStream): Int {
|
||||
val usedNames = mutableSetOf<String>()
|
||||
var written = 0
|
||||
ZipOutputStream(out).use { zip ->
|
||||
for (receipt in receipts) {
|
||||
val imageUri = receipt.imageUri ?: continue
|
||||
val baseName = receipt.photoExportName() ?: continue
|
||||
val name = uniqueName(baseName, usedNames)
|
||||
usedNames += name
|
||||
|
||||
val bytes = try {
|
||||
resolver.openInputStream(Uri.parse(imageUri))?.use { it.readBytes() }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
} ?: continue
|
||||
|
||||
zip.putNextEntry(ZipEntry(name))
|
||||
zip.write(bytes)
|
||||
zip.closeEntry()
|
||||
written++
|
||||
}
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
private fun uniqueName(baseName: String, used: Set<String>): String {
|
||||
if (baseName !in used) return baseName
|
||||
val stem = baseName.removeSuffix(".jpg")
|
||||
var n = 2
|
||||
while ("${stem}_$n.jpg" in used) n++
|
||||
return "${stem}_$n.jpg"
|
||||
}
|
||||
@@ -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,64 @@
|
||||
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.export.exportPhotos as writePhotosZip
|
||||
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)
|
||||
|
||||
/** Zips every receipt's photo as IMG_YYYYMMDD_HHMMSS.jpg (from the
|
||||
* receipt's own date/time, not the photo's capture time) - see
|
||||
* export/PhotoExporter.kt's photoExportName(). Receipts with no photo
|
||||
* or no parseable date/time are skipped, not failed. */
|
||||
fun exportPhotos(onReady: (Uri) -> Unit) {
|
||||
val app = getApplication<Application>()
|
||||
viewModelScope.launch {
|
||||
val receipts = repository.receipts.first()
|
||||
val (uri, written) = withContext(Dispatchers.IO) {
|
||||
val dir = File(app.cacheDir, "exports").apply { mkdirs() }
|
||||
val file = File(dir, "fuelish-photos.zip")
|
||||
val count = file.outputStream().use { writePhotosZip(receipts, app.contentResolver, it) }
|
||||
FileProvider.getUriForFile(app, "${app.packageName}.fileprovider", file) to count
|
||||
}
|
||||
AppLog.log("Exported $written of ${receipts.size} photo(s) to fuelish-photos.zip")
|
||||
onReady(uri)
|
||||
}
|
||||
}
|
||||
|
||||
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,198 @@
|
||||
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),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(R.string.overview_export_photos)) },
|
||||
selected = false,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
exportViewModel.exportPhotos { uri -> shareFile(context, uri, "application/zip") }
|
||||
},
|
||||
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,105 @@
|
||||
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 config = configRepository.config.first()
|
||||
val baseUrl = config.serverUrl
|
||||
if (baseUrl.isBlank()) {
|
||||
_uiState.value = CaptureUiState.Error("Set the server URL in Settings first")
|
||||
return@launch
|
||||
}
|
||||
val jobId = repository.submitImage(baseUrl, config.apiKey, imageFile)
|
||||
pollUntilDone(baseUrl, config.apiKey, jobId)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = CaptureUiState.Error(e.message ?: "unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pollUntilDone(baseUrl: String, apiKey: String, jobId: String) {
|
||||
var lastStage: String? = null
|
||||
while (true) {
|
||||
val status = repository.pollJob(baseUrl, apiKey, 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,167 @@
|
||||
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.Clear
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.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()
|
||||
val sort by viewModel.sort.collectAsState()
|
||||
val searchQuery by viewModel.searchQuery.collectAsState()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
SearchField(
|
||||
query = searchQuery,
|
||||
onQueryChange = viewModel::setSearchQuery,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
ReceiptTableHeader(sort = sort, onColumnClick = viewModel::setSortColumn)
|
||||
HorizontalDivider()
|
||||
|
||||
if (receipts.isEmpty()) {
|
||||
Text(
|
||||
text = if (searchQuery.isBlank()) {
|
||||
stringResource(R.string.overview_empty)
|
||||
} else {
|
||||
stringResource(R.string.overview_empty_search, searchQuery)
|
||||
},
|
||||
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 SearchField(query: String, onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = { Text(stringResource(R.string.overview_search_placeholder)) },
|
||||
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(Icons.Filled.Clear, contentDescription = stringResource(R.string.overview_search_clear))
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReceiptTableHeader(sort: SortState, onColumnClick: (SortColumn) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SortableHeaderCell(
|
||||
label = stringResource(R.string.overview_column_date),
|
||||
column = SortColumn.DATE,
|
||||
sort = sort,
|
||||
onClick = onColumnClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
SortableHeaderCell(
|
||||
label = stringResource(R.string.overview_column_litres),
|
||||
column = SortColumn.LITRES,
|
||||
sort = sort,
|
||||
onClick = onColumnClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
SortableHeaderCell(
|
||||
label = stringResource(R.string.overview_column_total),
|
||||
column = SortColumn.TOTAL_PRICE,
|
||||
sort = sort,
|
||||
onClick = onColumnClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Row(modifier = Modifier.width(DELETE_COLUMN_WIDTH)) {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SortableHeaderCell(
|
||||
label: String,
|
||||
column: SortColumn,
|
||||
sort: SortState,
|
||||
onClick: (SortColumn) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.clickable { onClick(column) },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(text = label, style = MaterialTheme.typography.labelLarge)
|
||||
if (sort.column == column) {
|
||||
Icon(
|
||||
imageVector = if (sort.direction == SortDirection.ASCENDING) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(start = 2.dp).width(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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,109 @@
|
||||
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.export.toExportRow
|
||||
import de.ladkau.fuelish.log.AppLog
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
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.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** The overview table's 3 visible/sortable columns (price/litre and
|
||||
* discount are detail-only, not shown in the list - see
|
||||
* ReceiptDetailScreen). DATE sorts by the combined date+time instant,
|
||||
* not the date string alone, since within-day ordering still matters for
|
||||
* a fuel-cost log. */
|
||||
enum class SortColumn { DATE, LITRES, TOTAL_PRICE }
|
||||
|
||||
enum class SortDirection { ASCENDING, DESCENDING }
|
||||
|
||||
data class SortState(val column: SortColumn, val direction: SortDirection)
|
||||
|
||||
class OverviewViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val repository = ReceiptRepository(application)
|
||||
|
||||
private val _sort = MutableStateFlow(SortState(SortColumn.DATE, SortDirection.DESCENDING))
|
||||
val sort: StateFlow<SortState> = _sort.asStateFlow()
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
val receipts: StateFlow<List<Receipt>> = combine(repository.receipts, _searchQuery, _sort) { all, query, sort ->
|
||||
all.filter { matchesQuery(it, query) }.sortedWith(comparator(sort))
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
fun setSearchQuery(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
/** Tapping the already-active column flips its direction; tapping a
|
||||
* different one switches to it, ascending. */
|
||||
fun setSortColumn(column: SortColumn) {
|
||||
_sort.value = if (_sort.value.column == column) {
|
||||
_sort.value.copy(direction = _sort.value.direction.flipped())
|
||||
} else {
|
||||
SortState(column, SortDirection.ASCENDING)
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(receipt: Receipt) {
|
||||
viewModelScope.launch {
|
||||
repository.delete(receipt)
|
||||
AppLog.log("Deleted receipt (date=${receipt.date}, total=${receipt.totalPrice})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SortDirection.flipped() =
|
||||
if (this == SortDirection.ASCENDING) SortDirection.DESCENDING else SortDirection.ASCENDING
|
||||
|
||||
/** Substring match (case-insensitive) against the same formatted field
|
||||
* values CSV/ODS export uses (export/ReceiptRow.kt's toExportRow()) -
|
||||
* "fulltext" across the table's actual fields, not just the three shown
|
||||
* in the overview list. */
|
||||
private fun matchesQuery(receipt: Receipt, query: String): Boolean {
|
||||
if (query.isBlank()) return true
|
||||
val needle = query.trim()
|
||||
return receipt.toExportRow().any { it.contains(needle, ignoreCase = true) }
|
||||
}
|
||||
|
||||
private val DATE_TIME_FORMAT = SimpleDateFormat("dd.MM.yyyy HH:mm:ss", Locale.GERMANY)
|
||||
|
||||
/** Receipts with no parseable date+time sort to the very end regardless
|
||||
* of direction, rather than jumping between "first" and "last" depending
|
||||
* on which way the column is currently sorted. */
|
||||
private fun dateTimeSortKey(receipt: Receipt): Long {
|
||||
val date = receipt.date ?: return Long.MAX_VALUE
|
||||
val time = receipt.time ?: "00:00:00"
|
||||
return try {
|
||||
DATE_TIME_FORMAT.parse("$date $time")?.time ?: Long.MAX_VALUE
|
||||
} catch (e: ParseException) {
|
||||
Long.MAX_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
private fun comparator(sort: SortState): Comparator<Receipt> {
|
||||
val base: Comparator<Receipt> = when (sort.column) {
|
||||
SortColumn.DATE -> compareBy { dateTimeSortKey(it) }
|
||||
SortColumn.LITRES -> nullsLastDouble { it.litres }
|
||||
SortColumn.TOTAL_PRICE -> nullsLastDouble { it.totalPrice }
|
||||
}
|
||||
return if (sort.direction == SortDirection.DESCENDING) base.reversed() else base
|
||||
}
|
||||
|
||||
/** compareBy(nullsLast()) alone would put nulls first when .reversed() is
|
||||
* applied for descending order - build the null-handling into the base
|
||||
* comparator instead so nulls stay last in both directions, same as
|
||||
* dateTimeSortKey()'s Long.MAX_VALUE approach. */
|
||||
private fun nullsLastDouble(selector: (Receipt) -> Double?): Comparator<Receipt> =
|
||||
compareBy { selector(it) ?: Double.MAX_VALUE }
|
||||
@@ -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,71 @@
|
||||
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.text.input.PasswordVisualTransformation
|
||||
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(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = config.apiKey,
|
||||
onValueChange = { viewModel.update { c -> c.copy(apiKey = it) } },
|
||||
label = { Text(stringResource(R.string.settings_api_key)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
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,65 @@
|
||||
<?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_empty_search">No receipts match “%1$s”</string>
|
||||
<string name="overview_search_placeholder">Search</string>
|
||||
<string name="overview_search_clear">Clear search</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_photos">Export photos</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_api_key">API key (leave blank if the server has none set)</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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
+248
@@ -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" "$@"
|
||||
Vendored
+82
@@ -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%
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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]}")"
|
||||
ROOT="$(pwd)"
|
||||
|
||||
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
|
||||
|
||||
command -v docker >/dev/null 2>&1 \
|
||||
|| fail "docker not found in PATH"
|
||||
|
||||
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"
|
||||
TOOLS_IMAGE="${REGISTRY_IMAGE:-$TOOLS_IMAGE}"
|
||||
ANDROID_IMAGE="${REGISTRY_ANDROID_IMAGE:-$ANDROID_IMAGE}"
|
||||
fi
|
||||
|
||||
TOOLS_VERSION="$(<"$ROOT/tooling-container/VERSION")"
|
||||
[ -n "$TOOLS_VERSION" ] || fail "tooling-container/VERSION is empty"
|
||||
|
||||
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 "$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 "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"
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
0.1.0
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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.
|
||||
#
|
||||
# For a deployment reachable outside a trusted LAN, copy .env.example to
|
||||
# .env and set FUELISH_API_KEY - every /jobs request then needs a
|
||||
# matching X-API-Key header (see tooling-container/src/server.py).
|
||||
# Unset (the default), the server has no auth, same as before. This
|
||||
# server does not terminate TLS itself - put a reverse proxy in front of
|
||||
# it for anything public.
|
||||
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
|
||||
FUELISH_API_KEY: ${FUELISH_API_KEY:-}
|
||||
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
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copy to registry.env (gitignored, never commit the real values) and fill
|
||||
# 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
@@ -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"
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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: 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
|
||||
# ground-truth comparison
|
||||
#
|
||||
# Usage: ./test_batch.sh -in <test-data-dir>
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
ROOT="$(pwd)"
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
IN_DIR=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-in) IN_DIR="${2:-}"; shift 2 ;;
|
||||
*) fail "unknown argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$IN_DIR" ] || fail "usage: $0 -in <test-data-dir>"
|
||||
[ -d "$IN_DIR" ] || fail "input directory not found: $IN_DIR"
|
||||
|
||||
IN_ABS="$(realpath "$IN_DIR")"
|
||||
ODS_PATH="$(find "$IN_ABS" -maxdepth 1 -name '*.ods' | head -n1)"
|
||||
[ -n "$ODS_PATH" ] || fail "no .ods ground-truth file found in $IN_ABS"
|
||||
|
||||
PARENT_DIR="$(dirname "$IN_ABS")"
|
||||
BASE_NAME="$(basename "$IN_ABS")"
|
||||
SCRATCH_DIR="$PARENT_DIR/$BASE_NAME-scratch"
|
||||
RESULT_DIR="$PARENT_DIR/$BASE_NAME-result"
|
||||
mkdir -p "$SCRATCH_DIR" "$RESULT_DIR"
|
||||
|
||||
shopt -s nullglob
|
||||
IMAGES=("$IN_ABS"/*.jpg)
|
||||
[ "${#IMAGES[@]}" -gt 0 ] || fail "no .jpg files found in $IN_ABS"
|
||||
|
||||
echo "== Running pipeline over ${#IMAGES[@]} receipts =="
|
||||
for img in "${IMAGES[@]}"; do
|
||||
name="$(basename "${img%.jpg}")"
|
||||
receipt_scratch="$SCRATCH_DIR/$name"
|
||||
receipt_result="$RESULT_DIR/$name"
|
||||
mkdir -p "$receipt_scratch" "$receipt_result"
|
||||
echo "--- $name ---"
|
||||
./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:-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"
|
||||
|
||||
echo
|
||||
echo "== Done =="
|
||||
echo "Intermediate step output: $SCRATCH_DIR/<receipt>/"
|
||||
echo "Final results + summary: $RESULT_DIR/<receipt>/, $RESULT_DIR/summary.txt"
|
||||
Executable
+37
@@ -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
@@ -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.
|
||||
@@ -0,0 +1,23 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-deu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir \
|
||||
opencv-python-headless==4.10.0.84 \
|
||||
numpy==2.1.3 \
|
||||
pillow==11.0.0 \
|
||||
pytesseract==0.3.13 \
|
||||
requests==2.32.3 \
|
||||
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
|
||||
|
||||
WORKDIR /data
|
||||
ENTRYPOINT []
|
||||
@@ -0,0 +1 @@
|
||||
0.1.1
|
||||
@@ -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()
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test-only tool: compare a directory of extracted.json results (produced
|
||||
by running 1_preprocess_image.sh/2_ocr_image.sh/3_extract_data.sh over a
|
||||
set of receipts) against the ground-truth spreadsheet, matching rows by
|
||||
date and reporting per-field accuracy."""
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
|
||||
from odf.opendocument import load
|
||||
from odf.table import Table, TableRow, TableCell
|
||||
from odf.text import P
|
||||
|
||||
|
||||
def load_ground_truth(ods_path: str) -> dict:
|
||||
doc = load(ods_path)
|
||||
table = doc.spreadsheet.getElementsByType(Table)[0]
|
||||
rows = table.getElementsByType(TableRow)
|
||||
gt = {}
|
||||
for r in rows[1:]:
|
||||
cells = r.getElementsByType(TableCell)
|
||||
vals = []
|
||||
for c in cells:
|
||||
ps = c.getElementsByType(P)
|
||||
vals.append(" ".join(str(p) for p in ps))
|
||||
if not vals or not vals[0]:
|
||||
continue
|
||||
date = vals[0]
|
||||
try:
|
||||
gt[date] = {
|
||||
"time": vals[1],
|
||||
"price_per_litre": float(vals[2].replace(",", ".")) if vals[2] else None,
|
||||
"litres": float(vals[3].replace(",", ".")) if vals[3] else None,
|
||||
"discount": float(vals[4].replace("€", "").replace(",", ".").strip()) if vals[4] else None,
|
||||
"total_price": float(vals[5].replace("€", "").replace(",", ".").strip()) if vals[5] else None,
|
||||
}
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
return gt
|
||||
|
||||
|
||||
def close(a, b, tol=0.05):
|
||||
if a is None or b is None:
|
||||
return a is None and b is None
|
||||
return abs(abs(a) - abs(b)) <= tol
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("-gt", dest="ods_path", required=True, help="path to ground-truth .ods")
|
||||
parser.add_argument("-results", dest="results_dir", required=True,
|
||||
help="directory containing one subdirectory per receipt, each with an extracted.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
gt = load_ground_truth(args.ods_path)
|
||||
|
||||
fields = ["price_per_litre", "litres", "discount", "total_price"]
|
||||
field_ok = {k: 0 for k in fields}
|
||||
field_total = {k: 0 for k in fields}
|
||||
matched = 0
|
||||
total = 0
|
||||
|
||||
for json_path in sorted(glob.glob(os.path.join(args.results_dir, "*", "extracted.json"))):
|
||||
receipt = os.path.basename(os.path.dirname(json_path))
|
||||
total += 1
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
truth = gt.get(data.get("date"))
|
||||
if not truth:
|
||||
print(f"{receipt}: NO DATE MATCH (extracted date={data.get('date')!r})")
|
||||
continue
|
||||
|
||||
matched += 1
|
||||
bad = []
|
||||
for k in fields:
|
||||
field_total[k] += 1
|
||||
ok = close(data.get(k), truth[k])
|
||||
if ok:
|
||||
field_ok[k] += 1
|
||||
else:
|
||||
bad.append(k)
|
||||
status = "OK" if not bad else f"WRONG FIELDS {bad}"
|
||||
print(f"{receipt}: {status}")
|
||||
|
||||
print("\n=== SUMMARY ===")
|
||||
print(f"Receipts matched to ground truth: {matched}/{total}")
|
||||
for k in fields:
|
||||
print(f" {k}: {field_ok[k]}/{field_total[k]} correct")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Step 3: structure raw OCR text from a fuel receipt into JSON via a small
|
||||
text-only LLM served by Ollama on the host (reached over --network host)."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434/api/chat")
|
||||
MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:3b")
|
||||
# Ollama restarts its runner when a request's context size or thread count
|
||||
# differs from the loaded one; set these to match other clients of a shared
|
||||
# Ollama. OLLAMA_NUM_THREAD unset = Ollama's default.
|
||||
NUM_CTX = int(os.environ.get("OLLAMA_NUM_CTX", "3072"))
|
||||
NUM_THREAD = os.environ.get("OLLAMA_NUM_THREAD")
|
||||
|
||||
SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"petrol_station": {"type": ["string", "null"]},
|
||||
"transaction_number": {"type": ["string", "null"]},
|
||||
"date": {"type": ["string", "null"], "description": "DD.MM.YYYY"},
|
||||
"time": {"type": ["string", "null"], "description": "HH:MM:SS"},
|
||||
"price_per_litre": {"type": ["number", "null"]},
|
||||
"litres": {"type": ["number", "null"]},
|
||||
"discount": {"type": ["number", "null"]},
|
||||
"total_price": {"type": ["number", "null"]},
|
||||
},
|
||||
"required": [
|
||||
"petrol_station", "transaction_number", "date", "time",
|
||||
"price_per_litre", "litres", "discount", "total_price",
|
||||
],
|
||||
}
|
||||
|
||||
PROMPT_TEMPLATE = """The following is raw OCR text (with some noise/garbage from a security background pattern) extracted from a German fuel/petrol receipt. Extract these fields:
|
||||
|
||||
- petrol_station: the name of the petrol station / company (e.g. "Shell", "Aral", "JET")
|
||||
- transaction_number: the transaction number (look for "Transaktions-Nr." specifically, not "Beleg-Nr."), as a string
|
||||
- date: the receipt date, formatted DD.MM.YYYY
|
||||
- time: the receipt time, formatted HH:MM:SS (24h)
|
||||
- litres and price_per_litre: these two numbers appear together on one line, in this order:
|
||||
"<litres> l <price_per_litre> EUR/l" (the "l" litre symbol is often misread as "1" or "|").
|
||||
For example "23,50 l 1,749 EUR/l" means litres=23.50 and price_per_litre=1.749. These are
|
||||
two different numbers on the same line - don't confuse them with each other or with the
|
||||
total price/Gesamtbetrag.
|
||||
- discount: total discount amount in EUR as a positive decimal number (0 if none). Look for
|
||||
lines with words like "Rabatt", "Bonus", "Tankbonus", "Nachlass" followed by a negative EUR
|
||||
amount, e.g. "Tankbonus -0,15 EUR" means discount=0.15 (drop the minus sign).
|
||||
- total_price: the final total amount paid, in EUR, as a decimal number
|
||||
|
||||
Ignore garbled/nonsense words - they are OCR noise, not real data. If a field is not present, use null.
|
||||
|
||||
OCR TEXT:
|
||||
---
|
||||
{ocr_text}
|
||||
---
|
||||
|
||||
Respond with ONLY the JSON object, no other text.
|
||||
"""
|
||||
|
||||
|
||||
def structure(ocr_text: str) -> dict:
|
||||
options = {"temperature": 0, "num_ctx": NUM_CTX}
|
||||
if NUM_THREAD:
|
||||
options["num_thread"] = int(NUM_THREAD)
|
||||
payload = {
|
||||
"model": MODEL,
|
||||
"messages": [
|
||||
{"role": "user", "content": PROMPT_TEMPLATE.format(ocr_text=ocr_text)},
|
||||
],
|
||||
"format": SCHEMA,
|
||||
"stream": False,
|
||||
"options": options,
|
||||
}
|
||||
|
||||
start = time.time()
|
||||
resp = requests.post(OLLAMA_URL, json=payload, timeout=300)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
elapsed = time.time() - start
|
||||
|
||||
content = result["message"]["content"]
|
||||
parsed = json.loads(content)
|
||||
parsed["_elapsed_seconds"] = round(elapsed, 1)
|
||||
return parsed
|
||||
|
||||
|
||||
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()
|
||||
|
||||
os.makedirs(args.out_dir, exist_ok=True)
|
||||
with open(args.in_path, "r", encoding="utf-8") as f:
|
||||
ocr_text = f.read()
|
||||
|
||||
data = structure(ocr_text)
|
||||
|
||||
out_path = os.path.join(args.out_dir, "extracted.json")
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
print(out_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Step 2: run Tesseract OCR (German) over a preprocessed receipt image,
|
||||
writing the raw recognized text to the output directory for inspection
|
||||
before it is handed to the structuring step."""
|
||||
import argparse
|
||||
import os
|
||||
|
||||
import pytesseract
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def ocr(path: str, out_dir: str) -> str:
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
img = Image.open(path)
|
||||
text = pytesseract.image_to_string(img, lang="deu", config="--psm 3")
|
||||
|
||||
out_path = os.path.join(out_dir, "ocr.txt")
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
return out_path
|
||||
|
||||
|
||||
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()
|
||||
|
||||
out_path = ocr(args.in_path, args.out_dir)
|
||||
print(out_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Step 1: preprocess a receipt photo for OCR.
|
||||
|
||||
Applies a mild edge-preserving smoothing pass (to take the edge off the
|
||||
receipt's dotted security background pattern) followed by a global
|
||||
percentile-based contrast stretch. Every intermediate stage is written to
|
||||
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, 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 (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
|
||||
|
||||
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)
|
||||
img = ImageOps.exif_transpose(img).convert("L")
|
||||
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, 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)
|
||||
|
||||
stage = load_grayscale(path)
|
||||
cv2.imwrite(os.path.join(out_dir, "01_gray.png"), stage)
|
||||
|
||||
# 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, f"0{next_n}_final.png")
|
||||
cv2.imwrite(final_path, final)
|
||||
|
||||
return final_path
|
||||
|
||||
|
||||
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, args.variant)
|
||||
print(final_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/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, single-instance server with no need for persistence here (the
|
||||
Android app is the system of record for confirmed receipts).
|
||||
|
||||
Auth: set FUELISH_API_KEY to require a matching X-API-Key header on
|
||||
/jobs (GET /health stays open for reverse-proxy health checks). Leave it
|
||||
unset for the original LAN-only/no-auth setup. This server does not
|
||||
terminate TLS itself - see require_api_key()'s docstring.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import requests
|
||||
from fastapi import Depends, FastAPI, HTTPException, Security, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security.api_key import APIKeyHeader
|
||||
|
||||
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")
|
||||
API_KEY = os.environ.get("FUELISH_API_KEY", "")
|
||||
|
||||
app = FastAPI(title="fuelish")
|
||||
|
||||
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
|
||||
def require_api_key(provided: str | None = Security(_api_key_header)) -> None:
|
||||
"""No-op when FUELISH_API_KEY isn't set - the unchanged default for a
|
||||
LAN-only deployment with no key configured. When it is set, every
|
||||
request to a protected route must carry a matching X-API-Key header.
|
||||
This only checks the key; it doesn't terminate TLS - a reverse proxy
|
||||
in front of this server (e.g. Traefik) is expected to do that for any
|
||||
deployment reachable outside a trusted LAN."""
|
||||
if not API_KEY:
|
||||
return
|
||||
if provided != API_KEY:
|
||||
raise HTTPException(status_code=401, detail="invalid or missing API key")
|
||||
|
||||
_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, dependencies=[Depends(require_api_key)])
|
||||
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}", dependencies=[Depends(require_api_key)])
|
||||
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"]}
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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]}")"
|
||||
ROOT="$(pwd)"
|
||||
|
||||
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
|
||||
|
||||
command -v docker >/dev/null 2>&1 \
|
||||
|| fail "docker not found in PATH"
|
||||
|
||||
[ -f "$ROOT/registry.env" ] \
|
||||
|| fail "registry.env not found - copy registry.env.example to registry.env and fill in your registry credentials"
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
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}"
|
||||
|
||||
TOOLS_VERSION="$(<"$ROOT/tooling-container/VERSION")"
|
||||
[ -n "$TOOLS_VERSION" ] || fail "tooling-container/VERSION is empty"
|
||||
|
||||
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:$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 "Images: $REGISTRY_IMAGE:$TOOLS_VERSION, $REGISTRY_ANDROID_IMAGE:$ANDROID_VERSION"
|
||||
Reference in New Issue
Block a user