Compare commits
6 Commits
68e0cd8967
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ae2c9be564 | |||
| 8980f9599b | |||
| 7a91a37049 | |||
| ca85530b5a | |||
| 9bb1844cf4 | |||
| ad2ef1689e |
@@ -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=
|
||||||
@@ -57,15 +57,24 @@ jobs:
|
|||||||
|
|
||||||
- name: Publish to dl.ladkau.de
|
- name: Publish to dl.ladkau.de
|
||||||
# Uploads the APK over SFTP instead of using actions/upload-artifact
|
# Uploads the APK over SFTP instead of using actions/upload-artifact
|
||||||
# (whose zip wrapping can't be disabled). Only runs on push so PR
|
# (whose zip wrapping can't be disabled). Runs on push and on a
|
||||||
# builds don't publish.
|
# manual "Run workflow" dispatch (the on-demand way to publish an
|
||||||
if: gitea.event_name == 'push'
|
# 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: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
APK="$(ls dist/fuelish-*.apk)"
|
APK="$(ls dist/fuelish-*.apk)"
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
|
printf '%s\n' '${{ secrets.DL_SFTP_KEY }}' > ~/.ssh/dl_sftp_key
|
||||||
chmod 600 ~/.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 \
|
sftp -i ~/.ssh/dl_sftp_key -P 2223 \
|
||||||
-o StrictHostKeyChecking=accept-new \
|
-o StrictHostKeyChecking=accept-new \
|
||||||
uploader@dl.ladkau.de <<EOF
|
uploader@dl.ladkau.de <<EOF
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
# Local registry credentials (see registry.env.example)
|
# Local registry credentials (see registry.env.example)
|
||||||
/registry.env
|
/registry.env
|
||||||
|
|
||||||
|
# Local API key for docker-compose.yml's api service (see .env.example)
|
||||||
|
/.env
|
||||||
|
|
||||||
# Local Claude Code settings
|
# Local Claude Code settings
|
||||||
/.claude/settings.local.json
|
/.claude/settings.local.json
|
||||||
|
|
||||||
|
|||||||
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"
|
||||||
@@ -11,12 +11,17 @@ containers; there is no host Python environment to set up.
|
|||||||
## Pipeline
|
## Pipeline
|
||||||
|
|
||||||
Three verifiable, independently-inspectable steps, each a thin host
|
Three verifiable, independently-inspectable steps, each a thin host
|
||||||
script that runs the actual work inside the `fuelish/tools` container:
|
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,
|
1. `1_preprocess_image.sh -in <photo> -out <dir>` - grayscale conversion,
|
||||||
a mild bilateral filter, and a percentile contrast stretch. Writes
|
a mild bilateral filter, and a percentile contrast stretch (variant 0
|
||||||
every intermediate stage (`01_gray.png`, `02_bilateral.png`,
|
of `tooling-container/src/preprocess.py`'s 4 variants - see
|
||||||
`03_final.png`) so each step can be checked on its own.
|
`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
|
2. `2_ocr_image.sh -in <dir>/03_final.png -out <dir>` - Tesseract OCR
|
||||||
(German), writes `ocr.txt`.
|
(German), writes `ocr.txt`.
|
||||||
3. `3_extract_data.sh -in <dir>/ocr.txt -out <dir>` - sends the raw OCR
|
3. `3_extract_data.sh -in <dir>/ocr.txt -out <dir>` - sends the raw OCR
|
||||||
@@ -24,13 +29,29 @@ script that runs the actual work inside the `fuelish/tools` container:
|
|||||||
schema, writes `extracted.json`.
|
schema, writes `extracted.json`.
|
||||||
|
|
||||||
Preprocessing choice is evidence-based, not assumed: heavier processing
|
Preprocessing choice is evidence-based, not assumed: heavier processing
|
||||||
(strong denoising, CLAHE, unsharp masking) was tried and measured worse -
|
(strong denoising, CLAHE, naive unsharp masking, and a GIMP-derived
|
||||||
it amplifies the receipt's dotted security-background pattern into OCR
|
high-pass/edge-enhance combo - see
|
||||||
noise. The lighter bilateral+autocontrast approach in
|
`testing/2026_10_04_gimp_highpass_nlfilter_experiment.md`) consistently
|
||||||
`tooling-container/src/preprocess.py` is what survived testing.
|
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
|
## 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
|
- `0_run_ollama.sh [-model <name>]` - starts Ollama as its own long-lived
|
||||||
Docker container (`fuelish-ollama`, model weights in the named
|
Docker container (`fuelish-ollama`, model weights in the named
|
||||||
volume `fuelish-ollama-data`), pulling the model if not already
|
volume `fuelish-ollama-data`), pulling the model if not already
|
||||||
@@ -39,7 +60,8 @@ noise. The lighter bilateral+autocontrast approach in
|
|||||||
steps are short-lived and stateless (one `docker run` per receipt).
|
steps are short-lived and stateless (one `docker run` per receipt).
|
||||||
`3_extract_data.sh` reaches it via `--network host` at
|
`3_extract_data.sh` reaches it via `--network host` at
|
||||||
`http://127.0.0.1:11434` by default (override with `OLLAMA_URL`/
|
`http://127.0.0.1:11434` by default (override with `OLLAMA_URL`/
|
||||||
`OLLAMA_MODEL`).
|
`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
|
- `build-image.sh` / `upload-image.sh` - build/push the
|
||||||
`tooling-container/` image, tagged from `tooling-container/VERSION`.
|
`tooling-container/` image, tagged from `tooling-container/VERSION`.
|
||||||
Image name defaults to `fuelish/tools`, overridable via
|
Image name defaults to `fuelish/tools`, overridable via
|
||||||
@@ -50,22 +72,36 @@ noise. The lighter bilateral+autocontrast approach in
|
|||||||
creates sibling `<path>/<name>-scratch/` (intermediate step output per
|
creates sibling `<path>/<name>-scratch/` (intermediate step output per
|
||||||
receipt) and `<path>/<name>-result/` (final `extracted.json` per
|
receipt) and `<path>/<name>-result/` (final `extracted.json` per
|
||||||
receipt, plus `summary.txt`). Both are gitignored.
|
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
|
## Current state
|
||||||
|
|
||||||
Last full run against the 17-receipt ground-truth test set
|
Last full run against the 52-receipt ground-truth test set
|
||||||
(`testdata/2026_new/`, not checked in - see `.gitignore`):
|
(`testdata/2026_benzin_kosten/`, not checked in - see `.gitignore`),
|
||||||
|
with the adaptive preprocessing escalation described above:
|
||||||
|
|
||||||
- 15/17 receipts date-matched to ground truth (up from 11/17 with the
|
- 40/52 receipts date-matched to ground truth (up from 37/52 with the
|
||||||
original plain-grayscale preprocessing)
|
single-variant/baseline-only preprocessing)
|
||||||
- price_per_litre: 11/15 correct, litres: 9/15, discount: 11/15,
|
- price_per_litre: 34/40 correct, litres: 24/40, discount: 34/40,
|
||||||
total_price: 12/15
|
total_price: 24/40
|
||||||
|
|
||||||
Known remaining failure modes, not expected to be fixable by further
|
Known remaining failure modes:
|
||||||
preprocessing tuning:
|
|
||||||
|
|
||||||
- OCR garbling on the compact "litres EUR/l price" line on some receipts
|
- OCR garbling on the compact "litres EUR/l price" line on some receipts
|
||||||
(the raw text sometimes lacks recoverable digits)
|
(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
|
- Occasional year-digit misreads in the date causing a false date-match
|
||||||
failure
|
failure
|
||||||
- Low-resolution source photos (well below the ~4000x3000 the good
|
- Low-resolution source photos (well below the ~4000x3000 the good
|
||||||
@@ -79,9 +115,11 @@ text - a subtle failure mode worth remembering if the prompt is revised.
|
|||||||
|
|
||||||
## Not yet built
|
## Not yet built
|
||||||
|
|
||||||
Per the original idea (see git history / project notes): an Android app
|
The Android app (`android/`), the HTTP job API (`docker-compose.yml`/
|
||||||
to capture and confirm receipts, a backend server to store raw + confirmed
|
`server.py`), and cross-checking total_price against
|
||||||
submissions, a simple authenticated web UI to query/filter/export
|
price_per_litre * litres - discount (`android/.../ReceiptPlausibility.kt`
|
||||||
(CSV/JSON), and cross-checking total_price against
|
on the app side, `tooling-container/src/plausibility.py` on the pipeline
|
||||||
price_per_litre * litres - discount. None of that exists yet - this repo
|
side) all now exist - see `README.md` for how they fit together. Not yet
|
||||||
currently covers only the extraction pipeline.
|
built: a web UI to query/filter/export outside the app, and auth/TLS on
|
||||||
|
the server (deliberately out of scope for now - see `server.py`'s
|
||||||
|
docstring).
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ tooling-container/ Pipeline source (preprocess.py, ocr.py, extract.py,
|
|||||||
test_batch.sh Runs the full pipeline over a directory of test
|
test_batch.sh Runs the full pipeline over a directory of test
|
||||||
receipts and compares against its ground-truth
|
receipts and compares against its ground-truth
|
||||||
.ods - see AGENT.md's "Current state".
|
.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
|
docker-compose.yml Runs the pipeline as a long-lived HTTP job API
|
||||||
(api + ollama services) for the Android app.
|
(api + ollama services) for the Android app.
|
||||||
android/ The Android app: Kotlin + Jetpack Compose, Room
|
android/ The Android app: Kotlin + Jetpack Compose, Room
|
||||||
@@ -102,6 +106,19 @@ registry, published via `./upload-image.sh` after `./build-image.sh`)
|
|||||||
and publishes the APK to dl.ladkau.de on every push to the default
|
and publishes the APK to dl.ladkau.de on every push to the default
|
||||||
branch.
|
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
|
## Running
|
||||||
|
|
||||||
**Inspect the pipeline stage by stage**, for development/debugging one
|
**Inspect the pipeline stage by stage**, for development/debugging one
|
||||||
@@ -121,18 +138,40 @@ receipt at a time:
|
|||||||
# file in that directory
|
# 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**:
|
**Serve it for the app**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
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
|
Then, in the Android app's Settings tab, point it at
|
||||||
`http://<this-host>:8080` (both HTTP-only - see the app's
|
`http://<this-host>:8080`. Take a photo, watch the progress (upload →
|
||||||
`network_security_config.xml` - and LAN-only by design, no auth/TLS).
|
preprocess+OCR → extract), correct anything the model got wrong, and
|
||||||
Take a photo, watch the 4-stage progress (upload → preprocess → OCR →
|
save. The Receipts tab lists everything saved so far and exports to CSV
|
||||||
extract), correct anything the model got wrong, and save. The Receipts
|
or `.ods`.
|
||||||
tab lists everything saved so far and exports to CSV or `.ods`.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -75,5 +75,6 @@ dependencies {
|
|||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
|
implementation(libs.coil.compose)
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package de.ladkau.fuelish.data
|
package de.ladkau.fuelish.data
|
||||||
|
|
||||||
|
import de.ladkau.fuelish.log.AppLog
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
@@ -13,7 +15,7 @@ import okhttp3.OkHttpClient
|
|||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.asRequestBody
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
|
|
||||||
/** Talks to tooling-container/src/server.py's two endpoints. Every call
|
/** Talks to tooling-container/src/server.py's endpoints. Every call
|
||||||
* takes the server's base URL explicitly (from [ConfigRepository]) rather
|
* takes the server's base URL explicitly (from [ConfigRepository]) rather
|
||||||
* than storing it, since the user can change it from Settings mid-session. */
|
* than storing it, since the user can change it from Settings mid-session. */
|
||||||
class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
|
class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
|
||||||
@@ -23,25 +25,70 @@ class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
|
|||||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun submitImage(baseUrl: String, imageFile: File): String = withContext(Dispatchers.IO) {
|
// 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()
|
val body = MultipartBody.Builder()
|
||||||
.setType(MultipartBody.FORM)
|
.setType(MultipartBody.FORM)
|
||||||
.addFormDataPart("image", imageFile.name, imageFile.asRequestBody("image/jpeg".toMediaType()))
|
.addFormDataPart("image", imageFile.name, imageFile.asRequestBody("image/jpeg".toMediaType()))
|
||||||
.build()
|
.build()
|
||||||
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs").post(body).build()
|
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs").post(body)
|
||||||
|
.withApiKey(apiKey).build()
|
||||||
client.newCall(request).execute().use { response ->
|
client.newCall(request).execute().use { response ->
|
||||||
if (!response.isSuccessful) throw IOException("upload failed: HTTP ${response.code}")
|
if (!response.isSuccessful) throw IOException("upload failed: HTTP ${response.code}")
|
||||||
val text = response.body?.string().orEmpty()
|
val text = response.body?.string().orEmpty()
|
||||||
json.decodeFromString(CreateJobResponse.serializer(), text).jobId
|
json.decodeFromString(CreateJobResponse.serializer(), text).jobId
|
||||||
|
}.also { jobId -> AppLog.log("Uploaded ${imageFile.name} to $baseUrl - job $jobId created") }
|
||||||
|
} catch (e: IOException) {
|
||||||
|
AppLog.log("Upload to $baseUrl failed: ${e.message}")
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getJobStatus(baseUrl: String, jobId: String): JobStatusResponse = withContext(Dispatchers.IO) {
|
suspend fun getJobStatus(baseUrl: String, apiKey: String, jobId: String): JobStatusResponse = withContext(Dispatchers.IO) {
|
||||||
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs/$jobId").get().build()
|
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs/$jobId").get()
|
||||||
|
.withApiKey(apiKey).build()
|
||||||
client.newCall(request).execute().use { response ->
|
client.newCall(request).execute().use { response ->
|
||||||
if (!response.isSuccessful) throw IOException("status check failed: HTTP ${response.code}")
|
if (!response.isSuccessful) throw IOException("status check failed: HTTP ${response.code}")
|
||||||
val text = response.body?.string().orEmpty()
|
val text = response.body?.string().orEmpty()
|
||||||
json.decodeFromString(JobStatusResponse.serializer(), text)
|
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)
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package de.ladkau.fuelish.data
|
package de.ladkau.fuelish.data
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everything the app needs that isn't a saved receipt: just the fuelish
|
* Everything the app needs that isn't a saved receipt: the fuelish
|
||||||
* server's base URL (e.g. "http://192.168.1.10:8080") right now.
|
* 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].
|
* Persisted via Preferences DataStore, see [ConfigRepository].
|
||||||
*/
|
*/
|
||||||
data class AppConfig(
|
data class AppConfig(
|
||||||
val serverUrl: String = "",
|
val serverUrl: String = "",
|
||||||
|
val apiKey: String = "",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,15 +15,20 @@ private val Context.configDataStore: DataStore<Preferences> by preferencesDataSt
|
|||||||
class ConfigRepository(private val context: Context) {
|
class ConfigRepository(private val context: Context) {
|
||||||
private object Keys {
|
private object Keys {
|
||||||
val SERVER_URL = stringPreferencesKey("server_url")
|
val SERVER_URL = stringPreferencesKey("server_url")
|
||||||
|
val API_KEY = stringPreferencesKey("api_key")
|
||||||
}
|
}
|
||||||
|
|
||||||
val config: Flow<AppConfig> = context.configDataStore.data.map { prefs ->
|
val config: Flow<AppConfig> = context.configDataStore.data.map { prefs ->
|
||||||
AppConfig(serverUrl = prefs[Keys.SERVER_URL] ?: "")
|
AppConfig(
|
||||||
|
serverUrl = prefs[Keys.SERVER_URL] ?: "",
|
||||||
|
apiKey = prefs[Keys.API_KEY] ?: "",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun save(config: AppConfig) {
|
suspend fun save(config: AppConfig) {
|
||||||
context.configDataStore.edit { prefs ->
|
context.configDataStore.edit { prefs ->
|
||||||
prefs[Keys.SERVER_URL] = config.serverUrl
|
prefs[Keys.SERVER_URL] = config.serverUrl
|
||||||
|
prefs[Keys.API_KEY] = config.apiKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import androidx.room.Database
|
|||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
|
|
||||||
@Database(entities = [Receipt::class], version = 1, exportSchema = false)
|
// 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 class FuelishDatabase : RoomDatabase() {
|
||||||
abstract fun receiptDao(): ReceiptDao
|
abstract fun receiptDao(): ReceiptDao
|
||||||
|
|
||||||
@@ -19,7 +24,10 @@ abstract class FuelishDatabase : RoomDatabase() {
|
|||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
FuelishDatabase::class.java,
|
FuelishDatabase::class.java,
|
||||||
"fuelish.db",
|
"fuelish.db",
|
||||||
).build().also { instance = it }
|
)
|
||||||
|
.fallbackToDestructiveMigration(dropAllTables = true)
|
||||||
|
.build()
|
||||||
|
.also { instance = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,12 @@ data class JobStatusResponse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mirrors tooling-container/src/extract.py's SCHEMA. */
|
/** 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
|
@Serializable
|
||||||
data class ExtractedReceiptDto(
|
data class ExtractedReceiptDto(
|
||||||
val petrolStation: String? = null,
|
|
||||||
val transactionNumber: String? = null,
|
|
||||||
val date: String? = null,
|
val date: String? = null,
|
||||||
val time: String? = null,
|
val time: String? = null,
|
||||||
val pricePerLitre: Double? = null,
|
val pricePerLitre: Double? = null,
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import androidx.room.PrimaryKey
|
|||||||
* A confirmed receipt, as the user saved it after reviewing the server's
|
* A confirmed receipt, as the user saved it after reviewing the server's
|
||||||
* extraction. This app is the system of record - the server is
|
* extraction. This app is the system of record - the server is
|
||||||
* stateless processing only (see docker-compose.yml / server.py).
|
* 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")
|
@Entity(tableName = "receipts")
|
||||||
data class Receipt(
|
data class Receipt(
|
||||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
val petrolStation: String?,
|
|
||||||
val transactionNumber: String?,
|
|
||||||
/** DD.MM.YYYY, matching testdata/.../benzin_kosten.ods's ground-truth format. */
|
/** DD.MM.YYYY, matching testdata/.../benzin_kosten.ods's ground-truth format. */
|
||||||
val date: String?,
|
val date: String?,
|
||||||
/** HH:MM:SS. */
|
/** HH:MM:SS. */
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -13,9 +13,13 @@ class ReceiptRepository(context: Context) {
|
|||||||
|
|
||||||
val receipts: Flow<List<Receipt>> = dao.observeAll()
|
val receipts: Flow<List<Receipt>> = dao.observeAll()
|
||||||
|
|
||||||
suspend fun submitImage(baseUrl: String, imageFile: File): String = api.submitImage(baseUrl, imageFile)
|
suspend fun getById(id: Long): Receipt? = dao.getById(id)
|
||||||
|
|
||||||
suspend fun pollJob(baseUrl: String, jobId: String): JobStatusResponse = api.getJobStatus(baseUrl, jobId)
|
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 save(receipt: Receipt): Long = dao.insert(receipt)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -4,18 +4,13 @@ import de.ladkau.fuelish.data.Receipt
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The first 6 columns mirror testdata/.../benzin_kosten.ods's layout
|
* Mirrors testdata/.../benzin_kosten.ods's layout exactly (date, time,
|
||||||
* exactly (date, time, price_per_litre, litres, discount, total_price -
|
* price_per_litre, litres, discount, total_price - see
|
||||||
* see tooling-container/src/compare_ground_truth.py's column order and
|
* tooling-container/src/compare_ground_truth.py's column order and its
|
||||||
* its German-locale decimal-comma / "€"-suffix parsing, which this
|
* German-locale decimal-comma / "€"-suffix parsing, which this
|
||||||
* reproduces on the way out). petrol_station/transaction_number are
|
* reproduces on the way out).
|
||||||
* appended as extra trailing columns - present in this app's data but
|
|
||||||
* not in the original ground-truth sheet.
|
|
||||||
*/
|
*/
|
||||||
val EXPORT_HEADERS = listOf(
|
val EXPORT_HEADERS = listOf("Datum", "Uhrzeit", "Preis/Liter", "Liter", "Rabatt", "Gesamtpreis")
|
||||||
"Datum", "Uhrzeit", "Preis/Liter", "Liter", "Rabatt", "Gesamtpreis",
|
|
||||||
"Tankstelle", "Transaktionsnummer",
|
|
||||||
)
|
|
||||||
|
|
||||||
fun Receipt.toExportRow(): List<String> = listOf(
|
fun Receipt.toExportRow(): List<String> = listOf(
|
||||||
date.orEmpty(),
|
date.orEmpty(),
|
||||||
@@ -24,6 +19,4 @@ fun Receipt.toExportRow(): List<String> = listOf(
|
|||||||
litres?.let { String.format(Locale.GERMANY, "%.2f", it) }.orEmpty(),
|
litres?.let { String.format(Locale.GERMANY, "%.2f", it) }.orEmpty(),
|
||||||
discount?.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(),
|
totalPrice?.let { String.format(Locale.GERMANY, "%.2f €", it) }.orEmpty(),
|
||||||
petrolStation.orEmpty(),
|
|
||||||
transactionNumber.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
package de.ladkau.fuelish.ui
|
package de.ladkau.fuelish.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
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.material.icons.filled.Menu
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.ModalDrawerSheet
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
@@ -17,45 +22,64 @@ import androidx.compose.material3.rememberDrawerState
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
import de.ladkau.fuelish.R
|
import de.ladkau.fuelish.R
|
||||||
import de.ladkau.fuelish.ui.capture.CaptureScreen
|
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.overview.OverviewScreen
|
||||||
|
import de.ladkau.fuelish.ui.receipt.ReceiptDetailScreen
|
||||||
import de.ladkau.fuelish.ui.settings.ConfigScreen
|
import de.ladkau.fuelish.ui.settings.ConfigScreen
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private object Destinations {
|
private object Destinations {
|
||||||
const val CAPTURE = "capture"
|
|
||||||
const val OVERVIEW = "overview"
|
const val OVERVIEW = "overview"
|
||||||
|
const val CAPTURE = "capture"
|
||||||
const val SETTINGS = "settings"
|
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 Scan/Receipts/Settings -
|
/**
|
||||||
* the app's single navigation host. */
|
* 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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun FuelishApp() {
|
fun FuelishApp() {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
|
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
|
||||||
|
val exportViewModel: ExportViewModel = viewModel()
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
ModalDrawerSheet {
|
ModalDrawerSheet {
|
||||||
NavigationDrawerItem(
|
|
||||||
label = { Text(stringResource(R.string.nav_capture)) },
|
|
||||||
selected = currentRoute == Destinations.CAPTURE,
|
|
||||||
onClick = { navigateTo(navController, Destinations.CAPTURE); scope.launch { drawerState.close() } },
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
|
||||||
)
|
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
label = { Text(stringResource(R.string.nav_overview)) },
|
label = { Text(stringResource(R.string.nav_overview)) },
|
||||||
selected = currentRoute == Destinations.OVERVIEW,
|
selected = currentRoute == Destinations.OVERVIEW,
|
||||||
@@ -68,6 +92,40 @@ fun FuelishApp() {
|
|||||||
onClick = { navigateTo(navController, Destinations.SETTINGS); scope.launch { drawerState.close() } },
|
onClick = { navigateTo(navController, Destinations.SETTINGS); scope.launch { drawerState.close() } },
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -76,21 +134,47 @@ fun FuelishApp() {
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.app_name)) },
|
title = { Text(stringResource(R.string.app_name)) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
|
if (currentRoute in Destinations.DRAWER_DESTINATIONS) {
|
||||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||||
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.nav_settings))
|
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 ->
|
) { innerPadding ->
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = Destinations.CAPTURE,
|
startDestination = Destinations.OVERVIEW,
|
||||||
modifier = Modifier.padding(innerPadding),
|
modifier = Modifier.padding(innerPadding),
|
||||||
) {
|
) {
|
||||||
composable(Destinations.CAPTURE) { CaptureScreen() }
|
composable(Destinations.OVERVIEW) {
|
||||||
composable(Destinations.OVERVIEW) { OverviewScreen() }
|
OverviewScreen(onReceiptClick = { id -> navController.navigate(Destinations.receiptDetail(id)) })
|
||||||
|
}
|
||||||
|
composable(Destinations.CAPTURE) {
|
||||||
|
CaptureScreen(onSaved = { navController.popBackStack() })
|
||||||
|
}
|
||||||
composable(Destinations.SETTINGS) { ConfigScreen() }
|
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() })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,3 +187,12 @@ private fun navigateTo(navController: NavHostController, route: String) {
|
|||||||
restoreState = 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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package de.ladkau.fuelish.ui.capture
|
package de.ladkau.fuelish.ui.capture
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.PickVisualMediaRequest
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -11,11 +13,8 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Button
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -23,7 +22,6 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -32,12 +30,12 @@ import androidx.core.content.ContextCompat
|
|||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import de.ladkau.fuelish.R
|
import de.ladkau.fuelish.R
|
||||||
import de.ladkau.fuelish.data.ExtractedReceiptDto
|
|
||||||
import de.ladkau.fuelish.data.JobStatusResponse
|
import de.ladkau.fuelish.data.JobStatusResponse
|
||||||
|
import de.ladkau.fuelish.ui.receipt.ReceiptReviewScreen
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
|
fun CaptureScreen(onSaved: () -> Unit, viewModel: CaptureViewModel = viewModel()) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
@@ -53,7 +51,7 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun launchCamera() {
|
fun launchCamera() {
|
||||||
val dir = File(context.cacheDir, "captures").apply { mkdirs() }
|
val dir = File(context.filesDir, "receipts").apply { mkdirs() }
|
||||||
val file = File(dir, "capture-${System.currentTimeMillis()}.jpg")
|
val file = File(dir, "capture-${System.currentTimeMillis()}.jpg")
|
||||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
pendingFile = file
|
pendingFile = file
|
||||||
@@ -65,9 +63,26 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
|
|||||||
if (granted) launchCamera()
|
if (granted) launchCamera()
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
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) {
|
when (val state = uiState) {
|
||||||
is CaptureUiState.Idle -> Button(
|
is CaptureUiState.Idle -> Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
val hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
val hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||||
PackageManager.PERMISSION_GRANTED
|
PackageManager.PERMISSION_GRANTED
|
||||||
@@ -76,10 +91,21 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
|
|||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.capture_take_photo))
|
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 -> ProgressStage(stringResource(R.string.capture_stage_uploading), 0.2f)
|
is CaptureUiState.Uploading -> Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
|
ProgressStage(stringResource(R.string.capture_stage_uploading), 0.2f)
|
||||||
|
}
|
||||||
|
|
||||||
is CaptureUiState.Processing -> {
|
is CaptureUiState.Processing -> Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
val (labelRes, progress) = when (state.stage) {
|
val (labelRes, progress) = when (state.stage) {
|
||||||
JobStatusResponse.STATUS_PREPROCESSING -> R.string.capture_stage_preprocessing to 0.45f
|
JobStatusResponse.STATUS_PREPROCESSING -> R.string.capture_stage_preprocessing to 0.45f
|
||||||
JobStatusResponse.STATUS_OCR -> R.string.capture_stage_ocr to 0.7f
|
JobStatusResponse.STATUS_OCR -> R.string.capture_stage_ocr to 0.7f
|
||||||
@@ -89,9 +115,15 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
|
|||||||
ProgressStage(stringResource(labelRes), progress)
|
ProgressStage(stringResource(labelRes), progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
is CaptureUiState.Editing -> EditForm(draft = state.draft, onSave = { viewModel.save(it) })
|
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 -> Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
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))
|
Text(stringResource(R.string.capture_stage_error, state.message))
|
||||||
Button(onClick = { viewModel.reset() }) { Text(stringResource(R.string.capture_retry)) }
|
Button(onClick = { viewModel.reset() }) { Text(stringResource(R.string.capture_retry)) }
|
||||||
}
|
}
|
||||||
@@ -99,6 +131,23 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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
|
@Composable
|
||||||
private fun ProgressStage(label: String, progress: Float) {
|
private fun ProgressStage(label: String, progress: Float) {
|
||||||
Column(
|
Column(
|
||||||
@@ -109,62 +158,3 @@ private fun ProgressStage(label: String, progress: Float) {
|
|||||||
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
|
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EditForm(draft: ExtractedReceiptDto, onSave: (ExtractedReceiptDto) -> Unit) {
|
|
||||||
var petrolStation by remember { mutableStateOf(draft.petrolStation.orEmpty()) }
|
|
||||||
var transactionNumber by remember { mutableStateOf(draft.transactionNumber.orEmpty()) }
|
|
||||||
var date by remember { mutableStateOf(draft.date.orEmpty()) }
|
|
||||||
var time by remember { mutableStateOf(draft.time.orEmpty()) }
|
|
||||||
var pricePerLitre by remember { mutableStateOf(draft.pricePerLitre?.toString().orEmpty()) }
|
|
||||||
var litres by remember { mutableStateOf(draft.litres?.toString().orEmpty()) }
|
|
||||||
var discount by remember { mutableStateOf(draft.discount?.toString().orEmpty()) }
|
|
||||||
var totalPrice by remember { mutableStateOf(draft.totalPrice?.toString().orEmpty()) }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
LabeledField(R.string.capture_field_petrol_station, petrolStation) { petrolStation = it }
|
|
||||||
LabeledField(R.string.capture_field_transaction_number, transactionNumber) { transactionNumber = it }
|
|
||||||
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 }
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
onSave(
|
|
||||||
ExtractedReceiptDto(
|
|
||||||
petrolStation = petrolStation.ifBlank { null },
|
|
||||||
transactionNumber = transactionNumber.ifBlank { null },
|
|
||||||
date = date.ifBlank { null },
|
|
||||||
time = time.ifBlank { null },
|
|
||||||
pricePerLitre = pricePerLitre.toDoubleOrNull(),
|
|
||||||
litres = litres.toDoubleOrNull(),
|
|
||||||
discount = discount.toDoubleOrNull(),
|
|
||||||
totalPrice = totalPrice.toDoubleOrNull(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.align(Alignment.End),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.capture_save))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.ladkau.fuelish.ui.capture
|
package de.ladkau.fuelish.ui.capture
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import de.ladkau.fuelish.data.ExtractedReceiptDto
|
import de.ladkau.fuelish.data.ExtractedReceiptDto
|
||||||
|
|
||||||
sealed interface CaptureUiState {
|
sealed interface CaptureUiState {
|
||||||
@@ -9,6 +10,6 @@ sealed interface CaptureUiState {
|
|||||||
/** [stage] is one of JobStatusResponse's STATUS_* constants (preprocessing/ocr/extracting). */
|
/** [stage] is one of JobStatusResponse's STATUS_* constants (preprocessing/ocr/extracting). */
|
||||||
data class Processing(val stage: String) : CaptureUiState
|
data class Processing(val stage: String) : CaptureUiState
|
||||||
|
|
||||||
data class Editing(val draft: ExtractedReceiptDto) : CaptureUiState
|
data class Editing(val draft: ExtractedReceiptDto, val imageUri: Uri?) : CaptureUiState
|
||||||
data class Error(val message: String) : CaptureUiState
|
data class Error(val message: String) : CaptureUiState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import de.ladkau.fuelish.data.ExtractedReceiptDto
|
|||||||
import de.ladkau.fuelish.data.JobStatusResponse
|
import de.ladkau.fuelish.data.JobStatusResponse
|
||||||
import de.ladkau.fuelish.data.Receipt
|
import de.ladkau.fuelish.data.Receipt
|
||||||
import de.ladkau.fuelish.data.ReceiptRepository
|
import de.ladkau.fuelish.data.ReceiptRepository
|
||||||
|
import de.ladkau.fuelish.log.AppLog
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -35,32 +36,40 @@ class CaptureViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = CaptureUiState.Uploading
|
_uiState.value = CaptureUiState.Uploading
|
||||||
try {
|
try {
|
||||||
val baseUrl = configRepository.config.first().serverUrl
|
val config = configRepository.config.first()
|
||||||
|
val baseUrl = config.serverUrl
|
||||||
if (baseUrl.isBlank()) {
|
if (baseUrl.isBlank()) {
|
||||||
_uiState.value = CaptureUiState.Error("Set the server URL in Settings first")
|
_uiState.value = CaptureUiState.Error("Set the server URL in Settings first")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val jobId = repository.submitImage(baseUrl, imageFile)
|
val jobId = repository.submitImage(baseUrl, config.apiKey, imageFile)
|
||||||
pollUntilDone(baseUrl, jobId)
|
pollUntilDone(baseUrl, config.apiKey, jobId)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = CaptureUiState.Error(e.message ?: "unknown error")
|
_uiState.value = CaptureUiState.Error(e.message ?: "unknown error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun pollUntilDone(baseUrl: String, jobId: String) {
|
private suspend fun pollUntilDone(baseUrl: String, apiKey: String, jobId: String) {
|
||||||
|
var lastStage: String? = null
|
||||||
while (true) {
|
while (true) {
|
||||||
val status = repository.pollJob(baseUrl, jobId)
|
val status = repository.pollJob(baseUrl, apiKey, jobId)
|
||||||
when (status.status) {
|
when (status.status) {
|
||||||
JobStatusResponse.STATUS_DONE -> {
|
JobStatusResponse.STATUS_DONE -> {
|
||||||
_uiState.value = CaptureUiState.Editing(status.result ?: ExtractedReceiptDto())
|
AppLog.log("Job $jobId done")
|
||||||
|
_uiState.value = CaptureUiState.Editing(status.result ?: ExtractedReceiptDto(), pendingImageUri)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
JobStatusResponse.STATUS_ERROR -> {
|
JobStatusResponse.STATUS_ERROR -> {
|
||||||
|
AppLog.log("Job $jobId failed: ${status.error}")
|
||||||
_uiState.value = CaptureUiState.Error(status.error ?: "processing failed")
|
_uiState.value = CaptureUiState.Error(status.error ?: "processing failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
if (status.status != lastStage) {
|
||||||
|
AppLog.log("Job $jobId: ${status.status}")
|
||||||
|
lastStage = status.status
|
||||||
|
}
|
||||||
_uiState.value = CaptureUiState.Processing(status.status)
|
_uiState.value = CaptureUiState.Processing(status.status)
|
||||||
delay(POLL_INTERVAL_MILLIS)
|
delay(POLL_INTERVAL_MILLIS)
|
||||||
}
|
}
|
||||||
@@ -68,12 +77,10 @@ class CaptureViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun save(draft: ExtractedReceiptDto) {
|
fun save(draft: ExtractedReceiptDto, onSaved: () -> Unit) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.save(
|
repository.save(
|
||||||
Receipt(
|
Receipt(
|
||||||
petrolStation = draft.petrolStation,
|
|
||||||
transactionNumber = draft.transactionNumber,
|
|
||||||
date = draft.date,
|
date = draft.date,
|
||||||
time = draft.time,
|
time = draft.time,
|
||||||
pricePerLitre = draft.pricePerLitre,
|
pricePerLitre = draft.pricePerLitre,
|
||||||
@@ -84,8 +91,10 @@ class CaptureViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
createdAtEpochMillis = System.currentTimeMillis(),
|
createdAtEpochMillis = System.currentTimeMillis(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
AppLog.log("Saved receipt (date=${draft.date}, total=${draft.totalPrice})")
|
||||||
pendingImageUri = null
|
pendingImageUri = null
|
||||||
_uiState.value = CaptureUiState.Idle
|
_uiState.value = CaptureUiState.Idle
|
||||||
|
onSaved()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -1,62 +1,71 @@
|
|||||||
package de.ladkau.fuelish.ui.overview
|
package de.ladkau.fuelish.ui.overview
|
||||||
|
|
||||||
import android.content.Intent
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
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.Delete
|
||||||
import androidx.compose.material3.Button
|
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.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import de.ladkau.fuelish.R
|
import de.ladkau.fuelish.R
|
||||||
import de.ladkau.fuelish.data.Receipt
|
import de.ladkau.fuelish.data.Receipt
|
||||||
|
|
||||||
|
private val DELETE_COLUMN_WIDTH = 48.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OverviewScreen(viewModel: OverviewViewModel = viewModel()) {
|
fun OverviewScreen(onReceiptClick: (Long) -> Unit, viewModel: OverviewViewModel = viewModel()) {
|
||||||
val receipts by viewModel.receipts.collectAsState()
|
val receipts by viewModel.receipts.collectAsState()
|
||||||
val context = LocalContext.current
|
val sort by viewModel.sort.collectAsState()
|
||||||
|
val searchQuery by viewModel.searchQuery.collectAsState()
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Row(
|
SearchField(
|
||||||
modifier = Modifier
|
query = searchQuery,
|
||||||
.fillMaxWidth()
|
onQueryChange = viewModel::setSearchQuery,
|
||||||
.padding(16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
)
|
||||||
) {
|
ReceiptTableHeader(sort = sort, onColumnClick = viewModel::setSortColumn)
|
||||||
Button(onClick = { viewModel.exportCsv { uri -> shareFile(context, uri, "text/csv") } }) {
|
HorizontalDivider()
|
||||||
Text(stringResource(R.string.overview_export_csv))
|
|
||||||
}
|
|
||||||
Button(onClick = { viewModel.exportOds { uri -> shareFile(context, uri, "application/vnd.oasis.opendocument.spreadsheet") } }) {
|
|
||||||
Text(stringResource(R.string.overview_export_ods))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (receipts.isEmpty()) {
|
if (receipts.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.overview_empty),
|
text = if (searchQuery.isBlank()) {
|
||||||
|
stringResource(R.string.overview_empty)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.overview_empty_search, searchQuery)
|
||||||
|
},
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
items(receipts, key = { it.id }) { receipt ->
|
items(receipts, key = { it.id }) { receipt ->
|
||||||
ReceiptRow(receipt = receipt, onDelete = { viewModel.delete(receipt) })
|
ReceiptRow(
|
||||||
|
receipt = receipt,
|
||||||
|
onClick = { onReceiptClick(receipt.id) },
|
||||||
|
onDelete = { viewModel.delete(receipt) },
|
||||||
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,29 +74,94 @@ fun OverviewScreen(viewModel: OverviewViewModel = viewModel()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReceiptRow(receipt: Receipt, onDelete: () -> Unit) {
|
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(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Column {
|
SortableHeaderCell(
|
||||||
Text(text = receipt.date.orEmpty())
|
label = stringResource(R.string.overview_column_date),
|
||||||
Text(text = listOfNotNull(receipt.petrolStation, receipt.totalPrice?.let { "%.2f €".format(it) }).joinToString(" · "))
|
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)) {}
|
||||||
}
|
}
|
||||||
IconButton(onClick = onDelete) {
|
}
|
||||||
Icon(Icons.Filled.Delete, contentDescription = null)
|
|
||||||
|
@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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun shareFile(context: android.content.Context, uri: android.net.Uri, mimeType: String) {
|
@Composable
|
||||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
private fun ReceiptRow(receipt: Receipt, onClick: () -> Unit, onDelete: () -> Unit) {
|
||||||
type = mimeType
|
Row(
|
||||||
putExtra(Intent.EXTRA_STREAM, uri)
|
modifier = Modifier
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
.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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
context.startActivity(Intent.createChooser(intent, null))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,109 @@
|
|||||||
package de.ladkau.fuelish.ui.overview
|
package de.ladkau.fuelish.ui.overview
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.net.Uri
|
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import de.ladkau.fuelish.data.Receipt
|
import de.ladkau.fuelish.data.Receipt
|
||||||
import de.ladkau.fuelish.data.ReceiptRepository
|
import de.ladkau.fuelish.data.ReceiptRepository
|
||||||
import de.ladkau.fuelish.export.exportCsv as writeCsvRows
|
import de.ladkau.fuelish.export.toExportRow
|
||||||
import de.ladkau.fuelish.export.exportOds as writeOdsRows
|
import de.ladkau.fuelish.log.AppLog
|
||||||
import java.io.File
|
import java.text.ParseException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
/** 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) {
|
class OverviewViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
private val repository = ReceiptRepository(application)
|
private val repository = ReceiptRepository(application)
|
||||||
|
|
||||||
val receipts: StateFlow<List<Receipt>> = repository.receipts
|
private val _sort = MutableStateFlow(SortState(SortColumn.DATE, SortDirection.DESCENDING))
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
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) {
|
fun delete(receipt: Receipt) {
|
||||||
viewModelScope.launch { repository.delete(receipt) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun exportCsv(onReady: (Uri) -> Unit) = export("fuelish-receipts.csv", ::writeCsvRows, onReady)
|
|
||||||
|
|
||||||
fun exportOds(onReady: (Uri) -> Unit) = export("fuelish-receipts.ods", ::writeOdsRows, onReady)
|
|
||||||
|
|
||||||
private fun export(fileName: String, write: (List<Receipt>, java.io.OutputStream) -> Unit, onReady: (Uri) -> Unit) {
|
|
||||||
val app = getApplication<Application>()
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val uri = withContext(Dispatchers.IO) {
|
repository.delete(receipt)
|
||||||
val dir = File(app.cacheDir, "exports").apply { mkdirs() }
|
AppLog.log("Deleted receipt (date=${receipt.date}, total=${receipt.totalPrice})")
|
||||||
val file = File(dir, fileName)
|
|
||||||
file.outputStream().use { write(receipts.value, it) }
|
|
||||||
FileProvider.getUriForFile(app, "${app.packageName}.fileprovider", file)
|
|
||||||
}
|
|
||||||
onReady(uri)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ package de.ladkau.fuelish.ui.settings
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -12,6 +15,7 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import de.ladkau.fuelish.R
|
import de.ladkau.fuelish.R
|
||||||
@@ -20,6 +24,7 @@ import de.ladkau.fuelish.R
|
|||||||
fun ConfigScreen(viewModel: ConfigViewModel = viewModel()) {
|
fun ConfigScreen(viewModel: ConfigViewModel = viewModel()) {
|
||||||
val config by viewModel.config.collectAsState()
|
val config by viewModel.config.collectAsState()
|
||||||
val isDirty by viewModel.isDirty.collectAsState()
|
val isDirty by viewModel.isDirty.collectAsState()
|
||||||
|
val testResult by viewModel.testResult.collectAsState()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -31,12 +36,36 @@ fun ConfigScreen(viewModel: ConfigViewModel = viewModel()) {
|
|||||||
value = config.serverUrl,
|
value = config.serverUrl,
|
||||||
onValueChange = { viewModel.update { c -> c.copy(serverUrl = it) } },
|
onValueChange = { viewModel.update { c -> c.copy(serverUrl = it) } },
|
||||||
label = { Text(stringResource(R.string.settings_server_url)) },
|
label = { Text(stringResource(R.string.settings_server_url)) },
|
||||||
placeholder = { Text(stringResource(R.string.settings_server_url_hint)) },
|
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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) {
|
Button(onClick = { viewModel.save() }, enabled = isDirty) {
|
||||||
Text(stringResource(R.string.capture_save))
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ package de.ladkau.fuelish.ui.settings
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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.AppConfig
|
||||||
import de.ladkau.fuelish.data.ConfigRepository
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -14,8 +18,15 @@ import kotlinx.coroutines.flow.first
|
|||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
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) {
|
class ConfigViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
private val configRepository = ConfigRepository(application)
|
private val configRepository = ConfigRepository(application)
|
||||||
|
private val api = ApiClient()
|
||||||
|
|
||||||
// A local editable draft, seeded once from the persisted config below -
|
// A local editable draft, seeded once from the persisted config below -
|
||||||
// deliberately not a live collection of configRepository.config, so
|
// deliberately not a live collection of configRepository.config, so
|
||||||
@@ -29,16 +40,28 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
val isDirty: StateFlow<Boolean> = combine(_config, _savedConfig) { draft, saved -> draft != saved }
|
val isDirty: StateFlow<Boolean> = combine(_config, _savedConfig) { draft, saved -> draft != saved }
|
||||||
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||||
|
|
||||||
|
private val _testResult = MutableStateFlow<ConnectionTestResult?>(null)
|
||||||
|
val testResult: StateFlow<ConnectionTestResult?> = _testResult.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val loaded = configRepository.config.first()
|
val loaded = configRepository.config.first()
|
||||||
_config.value = loaded
|
// 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
|
_savedConfig.value = loaded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun update(transform: (AppConfig) -> AppConfig) {
|
fun update(transform: (AppConfig) -> AppConfig) {
|
||||||
_config.value = transform(_config.value)
|
_config.value = transform(_config.value)
|
||||||
|
_testResult.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun save() {
|
fun save() {
|
||||||
@@ -48,4 +71,21 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
_savedConfig.value = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,11 @@
|
|||||||
<string name="nav_capture">Scan receipt</string>
|
<string name="nav_capture">Scan receipt</string>
|
||||||
<string name="nav_overview">Receipts</string>
|
<string name="nav_overview">Receipts</string>
|
||||||
<string name="nav_settings">Settings</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_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_uploading">Uploading…</string>
|
||||||
<string name="capture_stage_preprocessing">Preprocessing image…</string>
|
<string name="capture_stage_preprocessing">Preprocessing image…</string>
|
||||||
<string name="capture_stage_ocr">Reading text (OCR)…</string>
|
<string name="capture_stage_ocr">Reading text (OCR)…</string>
|
||||||
@@ -14,8 +17,6 @@
|
|||||||
<string name="capture_stage_error">Something went wrong: %1$s</string>
|
<string name="capture_stage_error">Something went wrong: %1$s</string>
|
||||||
<string name="capture_retry">Retry</string>
|
<string name="capture_retry">Retry</string>
|
||||||
<string name="capture_save">Save</string>
|
<string name="capture_save">Save</string>
|
||||||
<string name="capture_field_petrol_station">Petrol station</string>
|
|
||||||
<string name="capture_field_transaction_number">Transaction number</string>
|
|
||||||
<string name="capture_field_date">Date (DD.MM.YYYY)</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_time">Time (HH:MM:SS)</string>
|
||||||
<string name="capture_field_price_per_litre">Price per litre</string>
|
<string name="capture_field_price_per_litre">Price per litre</string>
|
||||||
@@ -23,11 +24,42 @@
|
|||||||
<string name="capture_field_discount">Discount</string>
|
<string name="capture_field_discount">Discount</string>
|
||||||
<string name="capture_field_total_price">Total price</string>
|
<string name="capture_field_total_price">Total price</string>
|
||||||
|
|
||||||
<string name="overview_empty">No receipts saved yet</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_csv">Export CSV</string>
|
||||||
<string name="overview_export_ods">Export ODS</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="overview_export_done">Exported to %1$s</string>
|
||||||
|
|
||||||
<string name="settings_server_url">Server URL</string>
|
<string name="settings_server_url">Server URL</string>
|
||||||
<string name="settings_server_url_hint">http://192.168.1.10:8080</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>
|
</resources>
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Where the FileProvider (see AndroidManifest.xml) is allowed to serve
|
<!-- Where the FileProvider (see AndroidManifest.xml) is allowed to serve
|
||||||
content:// URIs from. Captured receipt photos are written to the
|
content:// URIs from. -->
|
||||||
cache dir by CaptureScreen before being handed to the camera intent
|
|
||||||
and then uploaded - see ui/capture/CaptureViewModel.kt. -->
|
|
||||||
<paths>
|
<paths>
|
||||||
<cache-path name="captures" path="captures/" />
|
<!-- 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),
|
<!-- Exported CSV/ODS files (see export/CsvExporter.kt, OdsExporter.kt),
|
||||||
shared out via an ACTION_SEND intent - see
|
shared out via an ACTION_SEND intent - regenerated on every
|
||||||
ui/overview/OverviewViewModel.kt. -->
|
export, so cache is fine here. -->
|
||||||
<cache-path name="exports" path="exports/" />
|
<cache-path name="exports" path="exports/" />
|
||||||
</paths>
|
</paths>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ kotlinxCoroutines = "1.11.0"
|
|||||||
room = "2.8.5"
|
room = "2.8.5"
|
||||||
splashscreen = "1.0.1"
|
splashscreen = "1.0.1"
|
||||||
okhttp = "4.12.0"
|
okhttp = "4.12.0"
|
||||||
|
coil = "3.0.4"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -48,6 +49,7 @@ androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscree
|
|||||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
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" }
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
||||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
|
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
@@ -9,6 +9,13 @@
|
|||||||
#
|
#
|
||||||
# Usage: docker compose up -d
|
# Usage: docker compose up -d
|
||||||
# API reachable at http://<this-host>:8080 from any device on the LAN.
|
# 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:
|
services:
|
||||||
ollama:
|
ollama:
|
||||||
image: ollama/ollama
|
image: ollama/ollama
|
||||||
@@ -26,6 +33,7 @@ services:
|
|||||||
OLLAMA_URL: http://ollama:11434/api/chat
|
OLLAMA_URL: http://ollama:11434/api/chat
|
||||||
OLLAMA_BASE_URL: http://ollama:11434
|
OLLAMA_BASE_URL: http://ollama:11434
|
||||||
OLLAMA_MODEL: qwen2.5:3b
|
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"]
|
command: ["uvicorn", "server:app", "--app-dir", "/opt/app/src", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
ports:
|
ports:
|
||||||
- "8080:8000"
|
- "8080:8000"
|
||||||
|
|||||||
+11
-9
@@ -1,17 +1,20 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Test-only tool: runs the full container pipeline (1_preprocess_image.sh,
|
# Test-only tool: runs the full container pipeline
|
||||||
# 2_ocr_image.sh, 3_extract_data.sh) over every *.jpg in a test-data
|
# (12_adaptive_preprocess_ocr.sh, 3_extract_data.sh) over every *.jpg in
|
||||||
# directory, then compares the results against that directory's
|
# a test-data directory, then compares the results against that
|
||||||
# ground-truth spreadsheet (benzin_kosten.ods). Everything -
|
# directory's ground-truth spreadsheet (benzin_kosten.ods). Everything -
|
||||||
# preprocessing, OCR, structuring, and the comparison itself - runs
|
# preprocessing, OCR, structuring, and the comparison itself - runs
|
||||||
# inside the tooling container; nothing here depends on a host Python
|
# inside the tooling container; nothing here depends on a host Python
|
||||||
# environment.
|
# environment.
|
||||||
#
|
#
|
||||||
# For an input directory <path>/<name>, two sibling directories are
|
# For an input directory <path>/<name>, two sibling directories are
|
||||||
# created (mirroring <path>/<name>):
|
# created (mirroring <path>/<name>):
|
||||||
# <path>/<name>-scratch/<receipt>/ - intermediate step output
|
# <path>/<name>-scratch/<receipt>/ - intermediate step output: one
|
||||||
# (01_gray.png, 02_bilateral.png,
|
# variant<N>/ subdir per
|
||||||
# 03_final.png, ocr.txt) for
|
# 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
|
# inspecting each step on its own
|
||||||
# <path>/<name>-result/<receipt>/ - final extracted.json per receipt,
|
# <path>/<name>-result/<receipt>/ - final extracted.json per receipt,
|
||||||
# plus summary.txt with the overall
|
# plus summary.txt with the overall
|
||||||
@@ -56,8 +59,7 @@ for img in "${IMAGES[@]}"; do
|
|||||||
receipt_result="$RESULT_DIR/$name"
|
receipt_result="$RESULT_DIR/$name"
|
||||||
mkdir -p "$receipt_scratch" "$receipt_result"
|
mkdir -p "$receipt_scratch" "$receipt_result"
|
||||||
echo "--- $name ---"
|
echo "--- $name ---"
|
||||||
./1_preprocess_image.sh -in "$img" -out "$receipt_scratch" >/dev/null
|
./12_adaptive_preprocess_ocr.sh -in "$img" -out "$receipt_scratch" >/dev/null
|
||||||
./2_ocr_image.sh -in "$receipt_scratch/03_final.png" -out "$receipt_scratch" >/dev/null
|
|
||||||
./3_extract_data.sh -in "$receipt_scratch/ocr.txt" -out "$receipt_result" >/dev/null
|
./3_extract_data.sh -in "$receipt_scratch/ocr.txt" -out "$receipt_result" >/dev/null
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
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,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.
|
||||||
@@ -1 +1 @@
|
|||||||
0.1.0
|
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()
|
||||||
@@ -10,6 +10,11 @@ import requests
|
|||||||
|
|
||||||
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434/api/chat")
|
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434/api/chat")
|
||||||
MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:3b")
|
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 = {
|
SCHEMA = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -57,6 +62,9 @@ Respond with ONLY the JSON object, no other text.
|
|||||||
|
|
||||||
|
|
||||||
def structure(ocr_text: str) -> dict:
|
def structure(ocr_text: str) -> dict:
|
||||||
|
options = {"temperature": 0, "num_ctx": NUM_CTX}
|
||||||
|
if NUM_THREAD:
|
||||||
|
options["num_thread"] = int(NUM_THREAD)
|
||||||
payload = {
|
payload = {
|
||||||
"model": MODEL,
|
"model": MODEL,
|
||||||
"messages": [
|
"messages": [
|
||||||
@@ -64,7 +72,7 @@ def structure(ocr_text: str) -> dict:
|
|||||||
],
|
],
|
||||||
"format": SCHEMA,
|
"format": SCHEMA,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
"options": {"temperature": 0, "num_ctx": 3072},
|
"options": options,
|
||||||
}
|
}
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -8,12 +8,22 @@ the output directory (01_gray, 02_bilateral, 03_final) so the effect of
|
|||||||
each step can be inspected on its own before trusting the final image to
|
each step can be inspected on its own before trusting the final image to
|
||||||
OCR.
|
OCR.
|
||||||
|
|
||||||
Heavier processing (strong denoising, local/CLAHE contrast, unsharp
|
Heavier processing (strong denoising, local/CLAHE contrast, naive unsharp
|
||||||
masking) was tried and tested worse: each one amplifies the security
|
masking) was tried and tested worse: each one amplifies the security
|
||||||
background pattern into OCR noise rather than suppressing it. This
|
background pattern into OCR noise rather than suppressing it. This
|
||||||
lighter two-step version was chosen because, across a set of receipts
|
lighter two-step version (variant 0 below, and the default) was chosen
|
||||||
that the plain-grayscale baseline OCR'd incorrectly, it recovered data
|
because, across a set of receipts that the plain-grayscale baseline
|
||||||
the baseline missed and never made a previously-correct read worse.
|
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 argparse
|
||||||
import os
|
import os
|
||||||
@@ -22,6 +32,13 @@ import cv2
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image, ImageOps
|
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:
|
def load_grayscale(path: str) -> np.ndarray:
|
||||||
img = Image.open(path)
|
img = Image.open(path)
|
||||||
@@ -29,23 +46,66 @@ def load_grayscale(path: str) -> np.ndarray:
|
|||||||
return np.array(img)
|
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:
|
def autocontrast(gray: np.ndarray, low_pct=2, high_pct=98) -> np.ndarray:
|
||||||
lo, hi = np.percentile(gray, (low_pct, high_pct))
|
lo, hi = np.percentile(gray, (low_pct, high_pct))
|
||||||
stretched = (gray.astype(np.float32) - lo) * 255.0 / (hi - lo)
|
stretched = (gray.astype(np.float32) - lo) * 255.0 / (hi - lo)
|
||||||
return np.clip(stretched, 0, 255).astype(np.uint8)
|
return np.clip(stretched, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
|
|
||||||
def preprocess(path: str, out_dir: str) -> str:
|
def preprocess(path: str, out_dir: str, variant: int = 0) -> str:
|
||||||
|
"""variant: 0=baseline (bilateral+autocontrast only, unchanged default
|
||||||
|
behavior), 1=+highpass_substitute, 2=+edge_enhance, 3=+both. See
|
||||||
|
adaptive.py for why this is a parameter rather than always-on."""
|
||||||
|
if not 0 <= variant < VARIANT_COUNT:
|
||||||
|
raise ValueError(f"variant must be 0-{VARIANT_COUNT - 1}, got {variant}")
|
||||||
|
|
||||||
os.makedirs(out_dir, exist_ok=True)
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
|
||||||
gray = load_grayscale(path)
|
stage = load_grayscale(path)
|
||||||
cv2.imwrite(os.path.join(out_dir, "01_gray.png"), gray)
|
cv2.imwrite(os.path.join(out_dir, "01_gray.png"), stage)
|
||||||
|
|
||||||
bilateral = cv2.bilateralFilter(gray, d=5, sigmaColor=50, sigmaSpace=50)
|
# Stage filenames only extend beyond the original 01/02/03 numbering
|
||||||
cv2.imwrite(os.path.join(out_dir, "02_bilateral.png"), bilateral)
|
# 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 = autocontrast(bilateral)
|
||||||
final_path = os.path.join(out_dir, "03_final.png")
|
final_path = os.path.join(out_dir, f"0{next_n}_final.png")
|
||||||
cv2.imwrite(final_path, final)
|
cv2.imwrite(final_path, final)
|
||||||
|
|
||||||
return final_path
|
return final_path
|
||||||
@@ -55,9 +115,10 @@ def main():
|
|||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("-in", dest="in_path", required=True)
|
parser.add_argument("-in", dest="in_path", required=True)
|
||||||
parser.add_argument("-out", dest="out_dir", 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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
final_path = preprocess(args.in_path, args.out_dir)
|
final_path = preprocess(args.in_path, args.out_dir, args.variant)
|
||||||
print(final_path)
|
print(final_path)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,13 @@ Jobs are processed asynchronously so the app can show real per-stage
|
|||||||
progress: POST /jobs returns immediately with a job id; GET /jobs/{id}
|
progress: POST /jobs returns immediately with a job id; GET /jobs/{id}
|
||||||
reports which of preprocessing/ocr/extracting is currently running, and
|
reports which of preprocessing/ocr/extracting is currently running, and
|
||||||
the final result once done. Job state lives in memory only - this is a
|
the final result once done. Job state lives in memory only - this is a
|
||||||
personal, LAN-only, single-instance server with no need for persistence
|
personal, single-instance server with no need for persistence here (the
|
||||||
here (the Android app is the system of record for confirmed receipts).
|
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 os
|
||||||
import shutil
|
import shutil
|
||||||
@@ -21,18 +26,34 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from fastapi import FastAPI, HTTPException, UploadFile
|
from fastapi import Depends, FastAPI, HTTPException, Security, UploadFile
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.security.api_key import APIKeyHeader
|
||||||
|
|
||||||
|
from adaptive import preprocess_and_ocr_adaptive
|
||||||
from extract import structure
|
from extract import structure
|
||||||
from ocr import ocr as run_ocr
|
|
||||||
from preprocess import preprocess
|
|
||||||
|
|
||||||
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434")
|
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")
|
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:3b")
|
||||||
|
API_KEY = os.environ.get("FUELISH_API_KEY", "")
|
||||||
|
|
||||||
app = FastAPI(title="fuelish")
|
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: dict[str, dict] = {}
|
||||||
_jobs_lock = threading.Lock()
|
_jobs_lock = threading.Lock()
|
||||||
|
|
||||||
@@ -85,15 +106,10 @@ def health() -> dict:
|
|||||||
def _process_job(job_id: str, image_path: str, scratch_dir: str) -> None:
|
def _process_job(job_id: str, image_path: str, scratch_dir: str) -> None:
|
||||||
try:
|
try:
|
||||||
_set_job(job_id, status="preprocessing")
|
_set_job(job_id, status="preprocessing")
|
||||||
final_path = preprocess(image_path, scratch_dir)
|
adaptive_result = preprocess_and_ocr_adaptive(image_path, scratch_dir)
|
||||||
|
|
||||||
_set_job(job_id, status="ocr")
|
|
||||||
ocr_txt_path = run_ocr(final_path, scratch_dir)
|
|
||||||
with open(ocr_txt_path, "r", encoding="utf-8") as f:
|
|
||||||
ocr_text = f.read()
|
|
||||||
|
|
||||||
_set_job(job_id, status="extracting")
|
_set_job(job_id, status="extracting")
|
||||||
result = structure(ocr_text)
|
result = structure(adaptive_result["ocr_text"])
|
||||||
|
|
||||||
_set_job(job_id, status="done", result=result)
|
_set_job(job_id, status="done", result=result)
|
||||||
except Exception as exc: # noqa: BLE001 - reported to the client, not swallowed
|
except Exception as exc: # noqa: BLE001 - reported to the client, not swallowed
|
||||||
@@ -102,7 +118,7 @@ def _process_job(job_id: str, image_path: str, scratch_dir: str) -> None:
|
|||||||
shutil.rmtree(scratch_dir, ignore_errors=True)
|
shutil.rmtree(scratch_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/jobs", status_code=201)
|
@app.post("/jobs", status_code=201, dependencies=[Depends(require_api_key)])
|
||||||
async def create_job(image: UploadFile) -> JSONResponse:
|
async def create_job(image: UploadFile) -> JSONResponse:
|
||||||
job_id = uuid.uuid4().hex
|
job_id = uuid.uuid4().hex
|
||||||
scratch_dir = tempfile.mkdtemp(prefix=f"fuelish-{job_id}-")
|
scratch_dir = tempfile.mkdtemp(prefix=f"fuelish-{job_id}-")
|
||||||
@@ -119,7 +135,7 @@ async def create_job(image: UploadFile) -> JSONResponse:
|
|||||||
return JSONResponse({"job_id": job_id}, status_code=201)
|
return JSONResponse({"job_id": job_id}, status_code=201)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/jobs/{job_id}")
|
@app.get("/jobs/{job_id}", dependencies=[Depends(require_api_key)])
|
||||||
def get_job(job_id: str) -> dict:
|
def get_job(job_id: str) -> dict:
|
||||||
with _jobs_lock:
|
with _jobs_lock:
|
||||||
job = _jobs.get(job_id)
|
job = _jobs.get(job_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user