6 Commits

Author SHA1 Message Date
ml ae2c9be564 Add photo export and receipt list sorting/search
build / build (push) Successful in 3m24s
Photo export (hamburger menu) zips every receipt's photo as
IMG_YYYYMMDD_HHMMSS.jpg, named from the receipt's own extracted
date/time rather than the photo's capture time, with collision-safe
naming for same-timestamp receipts.

The receipt list's Date/Litres/Total column headers are now tappable
to sort (tap again to flip direction), and a search field does a
case-insensitive substring match across every exported field, not
just the three shown in the list.
2026-10-04 19:17:55 +02:00
ml 8980f9599b Make Ollama context size and threads configurable
build / build (push) Successful in 3m23s
2026-10-04 12:45:54 +02:00
ml 7a91a37049 Add optional API key auth to the server
build / build (push) Successful in 3m4s
Deployments reachable outside a trusted LAN need some form of access
control. Set FUELISH_API_KEY and the server requires a matching
X-API-Key header on /jobs; unset, behavior is unchanged (no auth).
The Android app gets a matching Settings field. This only checks the
key - TLS termination is left to a reverse proxy in front of the
server, not handled here.
2026-10-04 11:41:55 +02:00
ml ca85530b5a Add adaptive preprocessing escalation and single-receipt test scripts
build / build (push) Successful in 2m57s
Preprocessing now tries progressively heavier variants per receipt
(plain -> high-pass -> edge-enhance -> both), stopping as soon as a
cheap LLM-free arithmetic check on the OCR text looks plausible, so
the extra processing only applies to receipts that need it. A flat
version of the same heavier processing regressed the full batch by
helping hard receipts but hurting easy ones; the adaptive version
improves date-matching 37/52 -> 40/52 with no regression (see
testing/2026_10_04_gimp_highpass_nlfilter_experiment.md).

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

- Overview is now the single front page (list + "+" FAB to scan)
  instead of a separate near-empty Scan tab; export moved from
  Overview into the drawer
- Drop petrol_station/transaction_number from storage, the edit
  form, and CSV/ODS export - not useful for a personal fuel-cost log
  (destructive Room migration, version 1->2 already shipped earlier)
- Add a live plausibility check (total_price vs. price_per_litre *
  litres - discount) shown as a green/red badge on the review screen
- Add a shared Details/Photo review screen (ReceiptReviewScreen),
  with pinch-to-zoom on the photo (new Coil dependency), reused both
  right after a scan and when drilling into an already-saved receipt
  from the list (ReceiptDetailScreen, with Update/Delete)
- Fix receipt photos not surviving an app restart: camera/gallery
  captures now write to internal storage (filesDir) instead of the
  cache dir, which the OS can clear independent of the database; the
  gallery picker was also saving the Photo Picker's own short-lived
  content:// Uri instead of a Uri onto our persisted copy
- App log: add a Copy button and selectable text
- Fix the connection status bar overlapping the system gesture nav
  bar under edge-to-edge
- Settings: prefill an editable example server URL, add a "Test
  connection" button; both it and the connection status bar now
  surface the actual failure reason, not just pass/fail
2026-10-03 06:44:22 +02:00
matthias 68e0cd8967 Adding testing experiments
build / build (push) Failing after 2m59s
2026-10-03 06:34:38 +02:00
46 changed files with 1825 additions and 296 deletions
+7
View File
@@ -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=
+8 -1
View File
@@ -66,8 +66,15 @@ jobs:
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
+3
View File
@@ -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
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Combines steps 1+2 (preprocess + OCR) adaptively: tries preprocess.py's
# variants cheapest-first (0=baseline, same as 1_preprocess_image.sh's
# default, up to 3=most elaborate), scoring each variant's OCR text with
# tooling-container/src/plausibility.py and stopping at the first one
# that looks receipt-consistent - no LLM call involved, so this costs
# nothing extra over the single-variant path for the receipts the
# baseline already reads correctly. See tooling-container/src/adaptive.py
# and testing/2026_10_04_gimp_highpass_nlfilter_experiment.md for why.
#
# Writes <out>/variant<N>/{01_gray,...,0X_final}.png and its own ocr.txt
# for every variant tried (for inspection - which variant was kept is in
# the printed summary) and <out>/ocr.txt (the winning variant's OCR
# text, ready for ./3_extract_data.sh).
#
# Usage: ./12_adaptive_preprocess_ocr.sh -in <photo> -out <scratch-dir>
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
ROOT="$(pwd)"
fail() { echo "FAIL: $*" >&2; exit 1; }
IN_PATH=""
OUT_DIR=""
while [ $# -gt 0 ]; do
case "$1" in
-in) IN_PATH="${2:-}"; shift 2 ;;
-out) OUT_DIR="${2:-}"; shift 2 ;;
*) fail "unknown argument: $1" ;;
esac
done
[ -n "$IN_PATH" ] && [ -n "$OUT_DIR" ] \
|| fail "usage: $0 -in <photo> -out <scratch-dir>"
[ -f "$IN_PATH" ] || fail "input file not found: $IN_PATH"
command -v docker >/dev/null 2>&1 || fail "docker not found in PATH"
IMAGE="${REGISTRY_IMAGE:-fuelish/tools}"
if [ -f "$ROOT/registry.env" ]; then
# shellcheck disable=SC1091
source "$ROOT/registry.env"
IMAGE="${REGISTRY_IMAGE:-$IMAGE}"
fi
VERSION="$(<"$ROOT/tooling-container/VERSION")"
IMAGE="$IMAGE:$VERSION"
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|| fail "$IMAGE not found locally - run ./build-image.sh first"
IN_ABS="$(realpath "$IN_PATH")"
IN_DIR="$(dirname "$IN_ABS")"
IN_FILE="$(basename "$IN_ABS")"
mkdir -p "$OUT_DIR"
OUT_ABS="$(realpath "$OUT_DIR")"
GROUP_ADD_ARGS=(--group-add "$(stat -c %g "$IN_DIR")")
OUT_GID="$(stat -c %g "$OUT_ABS")"
[ "$OUT_GID" = "$(stat -c %g "$IN_DIR")" ] || GROUP_ADD_ARGS+=(--group-add "$OUT_GID")
echo "== Adaptively preprocessing + OCRing $IN_FILE =="
docker run --rm \
--user "$(id -u):$(id -g)" \
"${GROUP_ADD_ARGS[@]}" \
-v "$IN_DIR:/data/in:ro" \
-v "$OUT_ABS:/data/out" \
"$IMAGE" \
python3 /opt/app/src/adaptive.py -in "/data/in/$IN_FILE" -out /data/out
echo "== Done =="
echo "Next: ./3_extract_data.sh -in $OUT_ABS/ocr.txt -out $OUT_ABS"
+62 -24
View File
@@ -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).
+31 -5
View File
@@ -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
@@ -134,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
+1
View File
@@ -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)
} }
@@ -32,13 +32,14 @@ class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
.readTimeout(3, TimeUnit.SECONDS) .readTimeout(3, TimeUnit.SECONDS)
.build() .build()
suspend fun submitImage(baseUrl: String, imageFile: File): String = withContext(Dispatchers.IO) { suspend fun submitImage(baseUrl: String, apiKey: String, imageFile: File): String = withContext(Dispatchers.IO) {
try { 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()
@@ -50,8 +51,9 @@ class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
} }
} }
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()
@@ -84,3 +86,9 @@ sealed interface HealthCheckResult {
data object Ok : HealthCheckResult data object Ok : HealthCheckResult
data class Failed(val message: String) : 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(),
) )
@@ -4,9 +4,12 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -42,6 +45,11 @@ fun ConnectionStatusBar(viewModel: ConnectionStatusViewModel = viewModel()) {
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant) .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), .padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -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,37 +22,59 @@ 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.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 LOG = "log"
const val RECEIPT_DETAIL_ARG = "receiptId"
const val RECEIPT_DETAIL = "receipt_detail/{$RECEIPT_DETAIL_ARG}"
fun receiptDetail(id: Long) = "receipt_detail/$id"
// Reached via the drawer, so the top bar shows the hamburger menu.
// Anything else (Capture, receipt detail) is reached by pushing on top
// of one of these, so it gets a back arrow instead - see FuelishApp's
// navigationIcon below.
val DRAWER_DESTINATIONS = setOf(OVERVIEW, SETTINGS, LOG)
} }
/** Burger menu = ModalNavigationDrawer, leading to Receipts/Scan/Settings/App /**
* log - the app's single navigation host. Receipts is the front page (start * Burger menu = ModalNavigationDrawer, leading to Receipts/Settings/App
* destination) since that's what most opens of the app want to see. */ * 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,
@@ -59,12 +86,6 @@ fun FuelishApp() {
onClick = { navigateTo(navController, Destinations.OVERVIEW); scope.launch { drawerState.close() } }, onClick = { navigateTo(navController, Destinations.OVERVIEW); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp), modifier = Modifier.padding(horizontal = 12.dp),
) )
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_settings)) }, label = { Text(stringResource(R.string.nav_settings)) },
selected = currentRoute == Destinations.SETTINGS, selected = currentRoute == Destinations.SETTINGS,
@@ -77,6 +98,34 @@ fun FuelishApp() {
onClick = { navigateTo(navController, Destinations.LOG); scope.launch { drawerState.close() } }, onClick = { navigateTo(navController, Destinations.LOG); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp), 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),
)
} }
}, },
) { ) {
@@ -85,12 +134,25 @@ 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() }, bottomBar = { ConnectionStatusBar() },
) { innerPadding -> ) { innerPadding ->
NavHost( NavHost(
@@ -98,10 +160,21 @@ fun FuelishApp() {
startDestination = Destinations.OVERVIEW, startDestination = Destinations.OVERVIEW,
modifier = Modifier.padding(innerPadding), modifier = Modifier.padding(innerPadding),
) { ) {
composable(Destinations.OVERVIEW) { OverviewScreen() } composable(Destinations.OVERVIEW) {
composable(Destinations.CAPTURE) { CaptureScreen() } 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(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() })
}
} }
} }
} }
@@ -114,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))
}
@@ -13,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
@@ -25,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
@@ -34,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
@@ -55,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
@@ -67,16 +63,25 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
if (granted) launchCamera() if (granted) launchCamera()
} }
val pickImageLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> val pickImageLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { pickedUri ->
if (uri != null) { if (pickedUri != null) {
val file = copyPickedImageToCache(context, uri) val file = copyPickedImageToCache(context, pickedUri)
if (file != null) viewModel.submit(file, uri) 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)
}
} }
} }
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
when (val state = uiState) { when (val state = uiState) {
is CaptureUiState.Idle -> Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { is CaptureUiState.Idle -> Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Button( Button(
onClick = { onClick = {
val hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == val hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
@@ -94,10 +99,13 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
Text(stringResource(R.string.capture_choose_gallery)) 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
@@ -107,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)) }
} }
@@ -119,9 +133,10 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
/** Gallery picks come back as a content:// Uri, not a File - submit() /** 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 * needs a real File for the multipart upload, so copy it into the same
* cache dir the camera flow writes to. */ * 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? { private fun copyPickedImageToCache(context: Context, uri: Uri): File? {
val dir = File(context.cacheDir, "captures").apply { mkdirs() } val dir = File(context.filesDir, "receipts").apply { mkdirs() }
val file = File(dir, "picked-${System.currentTimeMillis()}.jpg") val file = File(dir, "picked-${System.currentTimeMillis()}.jpg")
return try { return try {
context.contentResolver.openInputStream(uri)?.use { input -> context.contentResolver.openInputStream(uri)?.use { input ->
@@ -143,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
} }
@@ -36,27 +36,28 @@ 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 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 -> {
AppLog.log("Job $jobId done") AppLog.log("Job $jobId done")
_uiState.value = CaptureUiState.Editing(status.result ?: ExtractedReceiptDto()) _uiState.value = CaptureUiState.Editing(status.result ?: ExtractedReceiptDto(), pendingImageUri)
return return
} }
JobStatusResponse.STATUS_ERROR -> { JobStatusResponse.STATUS_ERROR -> {
@@ -76,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,
@@ -95,6 +94,7 @@ class CaptureViewModel(application: Application) : AndroidViewModel(application)
AppLog.log("Saved receipt (date=${draft.date}, total=${draft.totalPrice})") AppLog.log("Saved receipt (date=${draft.date}, total=${draft.totalPrice})")
pendingImageUri = null pendingImageUri = null
_uiState.value = CaptureUiState.Idle _uiState.value = CaptureUiState.Idle
onSaved()
} }
} }
@@ -1,11 +1,14 @@
package de.ladkau.fuelish.ui.log package de.ladkau.fuelish.ui.log
import androidx.compose.foundation.layout.Column 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.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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -14,7 +17,9 @@ 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.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.ladkau.fuelish.R import de.ladkau.fuelish.R
@@ -26,20 +31,33 @@ import java.util.Locale
@Composable @Composable
fun LogScreen() { fun LogScreen() {
val entries by AppLog.entries.collectAsState() val entries by AppLog.entries.collectAsState()
val clipboard = LocalClipboardManager.current
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
Button( Row(
onClick = { AppLog.clear() },
modifier = Modifier.padding(16.dp), 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)) Text(stringResource(R.string.log_clear))
} }
}
if (entries.isEmpty()) { if (entries.isEmpty()) {
Text(stringResource(R.string.log_empty), modifier = Modifier.padding(16.dp)) Text(stringResource(R.string.log_empty), modifier = Modifier.padding(16.dp))
} else { } else {
// Newest first - the most recent event (usually what someone // Newest first - the most recent event (usually what someone
// opened this screen to check) is what you want without scrolling. // 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()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
items(entries.asReversed()) { entry -> items(entries.asReversed()) { entry ->
Text( Text(
@@ -56,6 +74,7 @@ fun LogScreen() {
} }
} }
} }
}
private val TIME_FORMAT = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) private val TIME_FORMAT = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
@@ -1,7 +1,6 @@
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
@@ -11,19 +10,22 @@ 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.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
@@ -33,37 +35,37 @@ import de.ladkau.fuelish.data.Receipt
private val DELETE_COLUMN_WIDTH = 48.dp 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") } }) {
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))
}
}
ReceiptTableHeader()
HorizontalDivider() HorizontalDivider()
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()
} }
} }
@@ -72,26 +74,51 @@ fun OverviewScreen(viewModel: OverviewViewModel = viewModel()) {
} }
@Composable @Composable
private fun ReceiptTableHeader() { 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),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( SortableHeaderCell(
text = stringResource(R.string.overview_column_date), label = stringResource(R.string.overview_column_date),
style = MaterialTheme.typography.labelLarge, column = SortColumn.DATE,
sort = sort,
onClick = onColumnClick,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
Text( SortableHeaderCell(
text = stringResource(R.string.overview_column_station), label = stringResource(R.string.overview_column_litres),
style = MaterialTheme.typography.labelLarge, column = SortColumn.LITRES,
modifier = Modifier.weight(2f), sort = sort,
onClick = onColumnClick,
modifier = Modifier.weight(1f),
) )
Text( SortableHeaderCell(
text = stringResource(R.string.overview_column_total), label = stringResource(R.string.overview_column_total),
style = MaterialTheme.typography.labelLarge, column = SortColumn.TOTAL_PRICE,
sort = sort,
onClick = onColumnClick,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
Row(modifier = Modifier.width(DELETE_COLUMN_WIDTH)) {} Row(modifier = Modifier.width(DELETE_COLUMN_WIDTH)) {}
@@ -99,27 +126,42 @@ private fun ReceiptTableHeader() {
} }
@Composable @Composable
private fun ReceiptRow(receipt: Receipt, onDelete: () -> Unit) { private fun SortableHeaderCell(
label: String,
column: SortColumn,
sort: SortState,
onClick: (SortColumn) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.clickable { onClick(column) },
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = label, style = MaterialTheme.typography.labelLarge)
if (sort.column == column) {
Icon(
imageVector = if (sort.direction == SortDirection.ASCENDING) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
contentDescription = null,
modifier = Modifier.padding(start = 2.dp).width(16.dp),
)
}
}
}
@Composable
private fun ReceiptRow(receipt: Receipt, onClick: () -> Unit, onDelete: () -> Unit) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 4.dp), .padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text(text = receipt.date.orEmpty(), modifier = Modifier.weight(1f)) Text(text = receipt.date.orEmpty(), modifier = Modifier.weight(1f))
Text(text = receipt.petrolStation.orEmpty(), modifier = Modifier.weight(2f)) 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)) Text(text = receipt.totalPrice?.let { "%.2f €".format(it) }.orEmpty(), modifier = Modifier.weight(1f))
IconButton(onClick = onDelete, modifier = Modifier.width(DELETE_COLUMN_WIDTH)) { IconButton(onClick = onDelete, modifier = Modifier.width(DELETE_COLUMN_WIDTH)) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.overview_delete)) Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.overview_delete))
} }
} }
} }
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,28 +1,60 @@
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 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 { viewModelScope.launch {
@@ -30,22 +62,48 @@ class OverviewViewModel(application: Application) : AndroidViewModel(application
AppLog.log("Deleted receipt (date=${receipt.date}, total=${receipt.totalPrice})") AppLog.log("Deleted receipt (date=${receipt.date}, total=${receipt.totalPrice})")
} }
} }
}
fun exportCsv(onReady: (Uri) -> Unit) = export("fuelish-receipts.csv", ::writeCsvRows, onReady) private fun SortDirection.flipped() =
if (this == SortDirection.ASCENDING) SortDirection.DESCENDING else SortDirection.ASCENDING
fun exportOds(onReady: (Uri) -> Unit) = export("fuelish-receipts.ods", ::writeOdsRows, onReady) /** 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 fun export(fileName: String, write: (List<Receipt>, java.io.OutputStream) -> Unit, onReady: (Uri) -> Unit) { private val DATE_TIME_FORMAT = SimpleDateFormat("dd.MM.yyyy HH:mm:ss", Locale.GERMANY)
val app = getApplication<Application>()
viewModelScope.launch { /** Receipts with no parseable date+time sort to the very end regardless
val uri = withContext(Dispatchers.IO) { * of direction, rather than jumping between "first" and "last" depending
val dir = File(app.cacheDir, "exports").apply { mkdirs() } * on which way the column is currently sorted. */
val file = File(dir, fileName) private fun dateTimeSortKey(receipt: Receipt): Long {
file.outputStream().use { write(receipts.value, it) } val date = receipt.date ?: return Long.MAX_VALUE
FileProvider.getUriForFile(app, "${app.packageName}.fileprovider", file) val time = receipt.time ?: "00:00:00"
} return try {
AppLog.log("Exported ${receipts.value.size} receipt(s) to $fileName") DATE_TIME_FORMAT.parse("$date $time")?.time ?: Long.MAX_VALUE
onReady(uri) } 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),
)
},
)
}
@@ -15,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
@@ -38,6 +39,14 @@ fun ConfigScreen(viewModel: ConfigViewModel = viewModel()) {
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)) { 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))
+17 -3
View File
@@ -6,6 +6,7 @@
<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_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_choose_gallery">Choose from gallery</string>
@@ -16,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>
@@ -25,17 +24,31 @@
<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="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">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_date">Date</string>
<string name="overview_column_station">Station</string> <string name="overview_column_litres">Litres</string>
<string name="overview_column_total">Total</string> <string name="overview_column_total">Total</string>
<string name="overview_delete">Delete</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_example">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_connection">Test connection</string>
<string name="settings_test_testing">Testing…</string> <string name="settings_test_testing">Testing…</string>
<string name="settings_test_success">Connected successfully</string> <string name="settings_test_success">Connected successfully</string>
@@ -46,6 +59,7 @@
<string name="status_not_configured">No server configured - see Settings</string> <string name="status_not_configured">No server configured - see Settings</string>
<string name="status_checking">Checking connection…</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_clear">Clear log</string>
<string name="log_empty">Nothing logged yet</string> <string name="log_empty">Nothing logged yet</string>
</resources> </resources>
+10 -6
View File
@@ -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>
+2
View File
@@ -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" }
+8
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Test-only tool: runs preprocessing + OCR (1_preprocess_image.sh,
# 2_ocr_image.sh - variant 0/baseline only, deliberately not
# 12_adaptive_preprocess_ocr.sh) on a single receipt photo and prints the
# raw OCR text to stdout - no LLM extraction step, no ground-truth
# comparison, and no files left behind (unlike test_single.sh/
# test_batch.sh, which persist scratch/result directories and go through
# the real adaptive pipeline). Useful for quickly checking what Tesseract
# actually reads off one specific preprocessing variant in isolation -
# e.g. while iterating on tooling-container/src/preprocess.py - without
# the adaptive step's variant selection in the way, waiting on the LLM
# call, or needing ground truth.
#
# Usage: ./test_ocr.sh -in <photo.jpg>
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
fail() { echo "FAIL: $*" >&2; exit 1; }
IN_PATH=""
while [ $# -gt 0 ]; do
case "$1" in
-in) IN_PATH="${2:-}"; shift 2 ;;
*) fail "unknown argument: $1" ;;
esac
done
[ -n "$IN_PATH" ] || fail "usage: $0 -in <photo.jpg>"
[ -f "$IN_PATH" ] || fail "input file not found: $IN_PATH"
SCRATCH_DIR="$(mktemp -d)"
trap 'rm -rf "$SCRATCH_DIR"' EXIT
./1_preprocess_image.sh -in "$IN_PATH" -out "$SCRATCH_DIR" >/dev/null
./2_ocr_image.sh -in "$SCRATCH_DIR/03_final.png" -out "$SCRATCH_DIR" >/dev/null
cat "$SCRATCH_DIR/ocr.txt"
Executable
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Test-only tool: runs the full container pipeline
# (12_adaptive_preprocess_ocr.sh, 3_extract_data.sh) on a single receipt
# photo - the test_batch.sh equivalent for re-checking one receipt (e.g.
# a known failure, after a preprocessing/prompt tweak) without re-running
# a whole directory. If a ground-truth spreadsheet (*.ods) is found next
# to the photo, compares the result against it the same way
# test_batch.sh does; otherwise just prints the extracted JSON.
# Everything - preprocessing, OCR, structuring, and the comparison itself
# - runs inside the tooling container; nothing here depends on a host
# Python environment.
#
# For an input photo <path>/<name>.jpg, two sibling directories are
# created (mirroring test_batch.sh's layout, just for one receipt):
# <path>/<name>-scratch/<name>/ - intermediate step output: one
# variant<N>/ subdir per preprocessing
# variant tried, plus ocr.txt for the
# winning variant
# <path>/<name>-result/<name>/ - extracted.json, plus summary.txt
# if a ground-truth .ods was found
#
# Usage: ./test_single.sh -in <photo.jpg>
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
ROOT="$(pwd)"
fail() { echo "FAIL: $*" >&2; exit 1; }
IN_PATH=""
while [ $# -gt 0 ]; do
case "$1" in
-in) IN_PATH="${2:-}"; shift 2 ;;
*) fail "unknown argument: $1" ;;
esac
done
[ -n "$IN_PATH" ] || fail "usage: $0 -in <photo.jpg>"
[ -f "$IN_PATH" ] || fail "input file not found: $IN_PATH"
IN_ABS="$(realpath "$IN_PATH")"
IN_DIR="$(dirname "$IN_ABS")"
NAME="$(basename "${IN_ABS%.jpg}")"
SCRATCH_DIR="$IN_DIR/$NAME-scratch"
RESULT_DIR="$IN_DIR/$NAME-result"
receipt_scratch="$SCRATCH_DIR/$NAME"
receipt_result="$RESULT_DIR/$NAME"
mkdir -p "$receipt_scratch" "$receipt_result"
echo "== Running pipeline on $NAME =="
./12_adaptive_preprocess_ocr.sh -in "$IN_ABS" -out "$receipt_scratch" >/dev/null
./3_extract_data.sh -in "$receipt_scratch/ocr.txt" -out "$receipt_result" >/dev/null
echo
echo "== Extracted =="
cat "$receipt_result/extracted.json"
echo
ODS_PATH="$(find "$IN_DIR" -maxdepth 1 -name '*.ods' | head -n1)"
if [ -n "$ODS_PATH" ]; then
echo
echo "== Comparing against ground truth =="
IMAGE="${REGISTRY_IMAGE:-fuelish/tools}"
if [ -f "$ROOT/registry.env" ]; then
# shellcheck disable=SC1091
source "$ROOT/registry.env"
IMAGE="${REGISTRY_IMAGE:-$IMAGE}"
fi
VERSION="$(<"$ROOT/tooling-container/VERSION")"
IMAGE="$IMAGE:$VERSION"
ODS_DIR="$(dirname "$ODS_PATH")"
ODS_FILE="$(basename "$ODS_PATH")"
GROUP_ADD_ARGS=(--group-add "$(stat -c %g "$ODS_DIR")")
RESULT_GID="$(stat -c %g "$RESULT_DIR")"
[ "$RESULT_GID" = "$(stat -c %g "$ODS_DIR")" ] || GROUP_ADD_ARGS+=(--group-add "$RESULT_GID")
docker run --rm \
--user "$(id -u):$(id -g)" \
"${GROUP_ADD_ARGS[@]}" \
-v "$ODS_DIR:/data/gt:ro" \
-v "$RESULT_DIR:/data/results:ro" \
"$IMAGE" \
python3 /opt/app/src/compare_ground_truth.py -gt "/data/gt/$ODS_FILE" -results /data/results \
| tee "$RESULT_DIR/summary.txt"
else
echo "(no ground-truth .ods found next to $IN_PATH - skipping comparison)"
fi
echo
echo "== Done =="
echo "Intermediate step output: $receipt_scratch/"
echo "Final result: $receipt_result/extracted.json"
@@ -0,0 +1,82 @@
# 3-variant OCR cross-referencing experiment
Tested whether running Tesseract over three image variants of the same
receipt photo (original color photo, plain grayscale, and the existing
denoised/contrast-enhanced preprocessing) and asking the extraction LLM
to cross-reference all three per field would recover more correct data
than the single-OCR-pass baseline, particularly for blurry/low-quality
photos.
## Result
Mixed. One more receipt matched overall, two fields improved
meaningfully, but the original target field (`litres`) did not improve,
and `total_price` regressed.
| Metric | Baseline (1 OCR pass) | New (3-variant cross-ref) |
|---|---|---|
| Matched to ground truth | 15/17 | 16/17 |
| price_per_litre | 11/15 (73%) | 13/16 (81%) ↑ |
| litres | 9/15 (60%) | 9/16 (56%) — unchanged, still worst |
| discount | 11/15 (73%) | 15/16 (94%) ↑↑ |
| total_price | 12/15 (80%) | 11/16 (69%) ↓ |
## Root causes identified
1. **The litres/price-per-litre line is the hardest OCR target**, regardless
of variant count. Noise characters (`x2p 02`, `*/p`) precede the real
numbers and the litre symbol `l` reads as `1`/`]`/`|` in all three
variants alike, so this field stays wrong at the same rate.
2. **Three OCR readings don't guarantee the model picks the right one.**
On the blurry Jet receipt, the correct total `57,13` survives intact in
the original-photo variant, but the model still output `37.13`. Extra
(noisy) input sometimes confuses the small 3B model rather than helping
it reconcile.
3. **`total_price` regressed** — cross-referencing conflicting reads
apparently made the model second-guess fields it used to get right from
a single clean reading.
4. **Severe performance/stability cost** on this 2-core, GPU-less VM:
extraction latency became wildly inconsistent (1–19 minutes per receipt
vs. seconds before), and one run hit a hard timeout before the request
timeout was raised from 300s → 900s. The full 17-receipt batch that used
to finish in ~11 minutes now takes well over an hour.
## Working examples (fully correct — all 4 fields + date matched)
- `IMG_20260726_095120207`, `095321692`, `095428941`, `095442114`,
`095510481`, `095519964` — all Shell receipts with clean, high-contrast
OCR text across all three variants.
## Not-working examples
- **`IMG_20260525_182252232`** (the blurry Jet receipt that prompted this
experiment) — still no date match at all (extracted `13.05.2026` vs. true
`15.05.2026`). All three OCR variants scramble the header; even though
the correct total `57,13` is legible in the original-photo variant, the
model output `37.13` instead.
- **`IMG_20260525_182144541`** — worst case, everything wrong except
date/time/transaction number: extracted `litres=1.205` (should be ~23),
`price_per_litre=1.389` (plausible alone, but paired with nonsense
litres), `discount=-0.08` (sign flipped), `total_price=5.33`. All three
OCR variants are near-unreadable for this receipt.
- **`IMG_20260525_182116766`** — `litres` read correctly (`20.24`, matches
the OCR text `"20,24 1 1979 EUR/]"` cleanly) but `price_per_litre` came
out as `6.01` instead of `~1.979` — a pure extraction/reconciliation
error, not an OCR gap, since the right number is sitting right there in
the text.
- **`IMG_20260726_095312079`, `095335197`, `095416655`, `095452747`,
`095501803`** — all wrong only on `litres`, the recurring weak field.
- **`IMG_20260726_095215757`** — wrong on `litres` and `total_price`
together; the OCR text for that line is heavily corrupted
(`j 2 N N 4) ha 1: Hi A 1.869 EUR/`) in all three variants, so this one
genuinely lacks clean signal anywhere.
## Conclusion / recommendation
The extra OCR variants help in a few places (`discount`, `price_per_litre`)
but don't fix the core problem (`litres` line noise), introduce a new
failure mode (the model mis-reconciling conflicting-but-sometimes-correct
reads), and cost a lot of latency/stability on this hardware. Leaning
toward reverting to single-OCR unless a confidence-triggered fallback
(only pull extra variants when the primary reading looks incomplete) is
tried instead.
@@ -0,0 +1,182 @@
# GIMP High Pass + NL Filter preprocessing experiment
Tested whether a GIMP sharpening/denoising combo, applied to the photo
*before* our existing `preprocess.py` (grayscale → bilateral filter →
percentile autocontrast), recovers the compact "litres / EUR-per-litre"
price line that's the pipeline's worst-performing OCR target (see
`AGENT.md`'s "Known remaining failure modes"). Three stress-test receipts
with known-bad baseline OCR on that line (`testdata/2026_stress_test/`,
not checked in):
- **A** `IMG_20260525_182252232` — ground truth 29,31 l / 1,949 EUR/l
- **B** `IMG_20260525_182144541` — ground truth 17,88 l / 1,989 EUR/l
- **C** `IMG_20260929_182218993` — ground truth 18,86 l / 2,129 EUR/l
The recipe (discovered manually in GIMP's GUI, applied to the full
uncropped color photo):
1. **Gaussian Blur** (Size X=Y=4.15, Filter=Auto, Abyss=Clamp) with
**Blending Options: Mode=Grain Extract, Opacity=100** set directly in
the filter dialog — GIMP 2.10's GEGL-based "High Pass" filter has no
Script-Fu/PDB wrapper at all, so this is a Script-Fu-reachable
reproduction of it via the classic manual technique (blur, then
grain-extract-blend against the original).
2. **Levels**: input low=58, gamma=0.96, high=135; output 0–255.
3. **NL Filter**, filter-type=2 (edge-enhancement), alpha=0.69,
radius=0.52.
## Result
Every variant beat the pipeline's baseline (no GIMP step) on at least one
field, for all three images — confirming the general direction helps.
But no single step generalized on its own; only the full 2-step recipe
was consistently good-to-exact across all three:
| Image (ground truth) | Baseline | 1. Blur+GrainExtract | 2. +Levels | 3. NL Filter alone | 4. Full recipe (2+3) |
|---|---|---|---|---|---|
| **A** (29,31l / 1,949) | both unreadable | price≈missing, litres✗ | litres 29,91✗(1 digit) / price **1,949✓** | litres 28,31✗(1 digit) / price 1,983≈ | litres **29,31✓** / price 1,993≈ |
| **B** (17,88l / 1,989) | litres+price both wrong | litres **17,88✓** / price 1,909≈ | litres **17,88✓** / price **1,989✓** | litres✗missing / price≈1989 (garbled) | litres 17,B8≈(1 char) / price **1,989✓** |
| **C** (18,86l / 2,129) | litres wrong, price✗missing | litres **18,86✓** / price✗missing | litres **0.86✗** (broke it!) / price≈2,129 | litres **18,86✓** / price✗missing | litres **18,86✓** / price≈2,129 |
Note step 2 (Levels) actively *breaks* C's litres reading that was fine
after step 1 alone — a clear example of a step helping one receipt while
hurting another, which is why no single step is a safe generalization on
its own even though each one looks promising in isolation.
## Root causes / methodology notes
1. **GIMP's "High Pass" filter has no PDB/Script-Fu wrapper** — confirmed
by querying the full procedure database (`gimp-procedural-db-query`)
for anything matching `high.?pass`; only `plug-in-edge`,
`plug-in-unsharp-mask`, `plug-in-sharpen`, and `gimp-selection-sharpen`
exist. It's GEGL-only in this GIMP version (2.10.30). The manual
Gaussian-Blur-with-Grain-Extract-blend technique is the only
automatable equivalent.
2. **GIMP's own "default" vs "legacy" grain-extract blend-mode variants
turned out not to matter** — tested both (`LAYER-MODE-GRAIN-EXTRACT`
= 46 and `LAYER-MODE-GRAIN-EXTRACT-LEGACY` = 20 against the same
input): numerically near-identical output either way.
3. **A real, unresolved gap remains between GIMP's GUI-applied
Gaussian-Blur-with-in-dialog-blend and a classic two-layer Script-Fu
reproduction** (duplicate layer → blur → `gimp-layer-set-mode` →
flatten) of the *same nominal parameters*. The GUI version's grain-
extract output has roughly double the standard deviation (spread) of
the scripted version's, confirmed via direct pixel-value percentile
comparison (user's real `_5_1.jpg`: mean 118.5, std 40.4; scripted
equivalent: mean 128.0, std 8.7–23.2 across a wide blur-radius sweep
from 8 to 180 — never converges, ruling out a simple radius/scale
miscalibration). `plug-in-gauss`'s legacy-compat wrapper applies an
internal ~0.32× multiplier converting its "radius" input to GEGL's
`std-dev`, and the Grain Extract formula itself was confirmed correct
from GEGL source (`CLAMP(lower - upper + 0.5)` in normalized space).
The actual cause (most likely GEGL's abyss-policy/edge-handling or an
image-precision difference) was not pinned down — **GIMP batch
scripting cannot currently reproduce this step's real-world output
closely enough to trust for pipeline automation.**
4. Separately (fixed along the way, not the cause of the above): GIMP
batch-mode has two sharp edges worth remembering if scripted again —
(a) `gimp-console -b <script> -b '(gimp-quit 0)'` as two separate
`-b` flags does not reliably terminate the process on error or even
on success; always pipe one script via stdin (`-b -`) with
`(gimp-quit 0)` as the last form *inside* that same stream.
(b) `gimp-image-insert-layer`'s position argument needs an explicit
`0` (top of stack) — `-1` does not reliably mean "top" and silently
produces a no-op-looking flatten (new layer ends up under the base
layer, which then opaquely covers it).
## Native Python reimplementation and full-batch result — rejected
Reimplemented the two steps natively (no GIMP dependency) in
`tooling-container/src/preprocess.py`, inserted before the existing
bilateral+autocontrast stage:
- `highpass_substitute()`: `cv2.GaussianBlur` + the confirmed
`clip(gray - blurred + 128)` grain-extract formula + a linear
levels-stretch (low=58, high=135, gamma=0.96 — the same values as the
GIMP recipe).
- `edge_enhance()`: a calibrated unsharp-mask-style boost
(`gray + 0.69 * (gray - GaussianBlur(gray, sigma=0.52))`) as the
OpenCV substitute for GIMP's NL Filter edge-enhancement mode (no direct
OpenCV equivalent exists).
- Both steps' blur radii scale with image width (`/ 768`, the width A/B
were shot at) — necessary because C is 4096×3072, roughly 4-5x A/B's
linear resolution, and a fixed pixel radius calibrated at one scale
clearly under/over-shoots at the other (confirmed: before scaling, C's
litres read `0.86` instead of `18,86`; after scaling, `18,86✓`).
On the A/B/C set, this **outperformed the GIMP-based matrix** — litres
exact on all three images, price exact-or-close on all three (no GIMP
dependency, no reproduction-gap uncertainty).
**But it failed full-batch validation** against the real 52-receipt
ground-truth set (`testdata/2026_benzin_kosten/`), the required next step
per this project's evidence-based philosophy before touching the real
pipeline:
| Metric | Baseline (before) | With new preprocessing | Change |
|---|---|---|---|
| Date-matched | 37/52 | 30/52 | **-7** |
| price_per_litre | 27/37 (73%) | 18/30 (60%) | ↓ |
| litres (the targeted field) | 19/37 (51%) | 12/30 (40%) | ↓ worse |
| discount | 29/37 (78%) | 22/30 (73%) | ↓ |
| total_price | 23/37 (62%) | 18/30 (60%) | ↓ |
Regression across every metric, including the exact field the change
targeted — the same failure pattern as the earlier-rejected unsharp-
masking attempt (see `AGENT.md`): it visibly helps the specific hard
receipts it was designed for, but amplifies the dotted security-
background pattern into OCR noise on receipts that were already reading
fine, making the batch worse overall. **Reverted** from
`preprocess.py`/the numbered scripts/docs; not going into the pipeline.
## Adaptive per-receipt escalation — accepted
This is now the second distinct sharpening-family approach (after naive
unsharp masking - see `AGENT.md`) confirmed to fail *when applied
globally*: strong locally on hard receipts, net-negative on the full
batch, because it amplifies the security background pattern into noise
on receipts the cheap baseline already reads fine. Both failures point
at the same fix: apply it *selectively*, only to receipts the cheap pass
already reads poorly, rather than to every photo.
Implemented as `tooling-container/src/preprocess.py`'s four variants
(0=baseline, 1=+`highpass_substitute`, 2=+`edge_enhance`, 3=+both) tried
cheapest-first by `tooling-container/src/adaptive.py`, escalating only
when the current variant's raw OCR text fails a cheap arithmetic-
consistency check (`tooling-container/src/plausibility.py`:
`price_per_litre * litres - discount ≈ total_price` within 0.05 EUR —
the same identity and tolerance `android/.../ReceiptPlausibility.kt` and
`compare_ground_truth.py` already use, applied here via regex-extracted
candidate numbers straight from OCR text so the check needs no LLM call
and runs once per variant instead of once per receipt). This keeps the
expensive LLM structuring call to exactly one per receipt regardless of
how many variants are tried, since tier selection happens entirely
before that call.
Full-batch result against `testdata/2026_benzin_kosten/` (same baseline
as above):
| Metric | Baseline | Adaptive | Change |
|---|---|---|---|
| Date-matched | 37/52 | 40/52 | **+3** |
| price_per_litre | 27/37 (73%) | 34/40 (85%) | **↑** |
| litres | 19/37 (51%) | 24/40 (60%) | **↑** |
| discount | 29/37 (78%) | 34/40 (85%) | **↑** |
| total_price | 23/37 (62%) | 24/40 (60%) | ≈ flat (+1 in count) |
Improves every metric except total_price, which is essentially flat.
**Accepted** — wired into `server.py`, `test_batch.sh`, `test_single.sh`,
and the new `12_adaptive_preprocess_ocr.sh` (replacing the separate
`1_preprocess_image.sh` + `2_ocr_image.sh` calls those two use;
`1_preprocess_image.sh`/`2_ocr_image.sh` themselves are unchanged and
still useful for inspecting one variant in isolation).
## Conclusion
The litres/price-per-litre line was the pipeline's hardest OCR target
specifically because every fix tried so far has been a global tradeoff:
good for hard receipts, bad for easy ones, on the same underlying cause
(amplifying the security background pattern). Making the fix conditional
on the cheap pass already looking wrong - judged cheaply, without an
LLM call - was what actually let the hard-receipt win through without
the easy-receipt cost.
+1 -1
View File
@@ -1 +1 @@
0.1.0 0.1.1
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Adaptive preprocessing: tries preprocess.py's variants (0=cheapest,
up to VARIANT_COUNT-1=most elaborate) in order, OCRing each and scoring
the result with plausibility.best_residual() - no LLM call - stopping at
the first variant whose OCR text already looks receipt-consistent.
Exists because always applying the heavier variants regressed the full
test batch even though they clearly help the specific hard receipts they
were designed for (see
testing/2026_10_04_gimp_highpass_nlfilter_experiment.md): escalating only
when the cheap variant already looks implausible avoids that cost - and
that risk - on the majority of receipts the baseline already reads
correctly, while keeping the expensive LLM structuring call to exactly
one per receipt no matter how many variants are tried.
"""
import argparse
import os
from ocr import ocr as run_ocr
from plausibility import best_residual, is_plausible
from preprocess import VARIANT_COUNT, preprocess
def preprocess_and_ocr_adaptive(image_path: str, out_dir: str) -> dict:
"""Returns {"ocr_text", "final_image_path", "variant", "residual",
"variants_tried"} - "variant" is whichever one was kept (the first
plausible one, or else the one with the lowest residual), not
necessarily the last one tried."""
best = None # (residual, ocr_text, final_path, variant)
variants_tried = 0
for variant in range(VARIANT_COUNT):
variant_dir = os.path.join(out_dir, f"variant{variant}")
final_path = preprocess(image_path, variant_dir, variant=variant)
ocr_txt_path = run_ocr(final_path, variant_dir)
with open(ocr_txt_path, "r", encoding="utf-8") as f:
ocr_text = f.read()
variants_tried += 1
residual = best_residual(ocr_text)
if best is None or residual < best[0]:
best = (residual, ocr_text, final_path, variant)
if is_plausible(residual):
break
residual, ocr_text, final_path, variant = best
return {
"ocr_text": ocr_text,
"final_image_path": final_path,
"variant": variant,
"residual": residual,
"variants_tried": variants_tried,
}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("-in", dest="in_path", required=True)
parser.add_argument("-out", dest="out_dir", required=True)
args = parser.parse_args()
result = preprocess_and_ocr_adaptive(args.in_path, args.out_dir)
ocr_path = os.path.join(args.out_dir, "ocr.txt")
with open(ocr_path, "w", encoding="utf-8") as f:
f.write(result["ocr_text"])
print(
f"variant={result['variant']} "
f"variants_tried={result['variants_tried']} "
f"residual={result['residual']:.3f} "
f"final_image={result['final_image_path']}"
)
print(ocr_path)
if __name__ == "__main__":
main()
+9 -1
View File
@@ -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()
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Cheap, LLM-free plausibility scoring of raw OCR text from a German fuel
receipt. Used by adaptive.py to rank several preprocessing variants' OCR
output against each other - and decide whether to bother trying a more
elaborate variant at all - without paying for an LLM call per variant.
Mirrors the same arithmetic identity and EUR tolerance
android/.../ReceiptPlausibility.kt and compare_ground_truth.py use
(total_price == price_per_litre * litres - discount, within 0.05 EUR),
applied here to candidate numbers found directly in raw OCR text rather
than already-structured fields, since this runs before (and to help pick
input for) the single structuring LLM call.
"""
import re
TOLERANCE_EUR = 0.05
_NUMBER_RE = re.compile(r"(\d{1,3}),(\d{2,3})")
# Plausible-value ranges for German fuel receipts, used to keep the
# combination search small and to reject obviously-wrong candidates
# (transaction IDs, card digits, etc. pulled in by the same regex).
_PRICE_RANGE = (0.5, 3.5)
_LITRES_RANGE = (1.0, 150.0)
_TOTAL_RANGE = (1.0, 500.0)
_DISCOUNT_RANGE = (0.0, 50.0)
def _candidates(ocr_text: str) -> list[float]:
out = []
for m in _NUMBER_RE.finditer(ocr_text):
try:
out.append(float(f"{m.group(1)}.{m.group(2)}"))
except ValueError:
continue
return out
def best_residual(ocr_text: str) -> float:
"""Smallest |price_per_litre * litres - discount - total_price| over
plausible-range combinations of numbers found in ocr_text. Lower is
more plausible; see is_plausible() for the pass/fail threshold. inf
if the text doesn't contain enough candidate numbers in the right
ranges to try any combination at all."""
candidates = _candidates(ocr_text)
prices = [c for c in candidates if _PRICE_RANGE[0] <= c <= _PRICE_RANGE[1]]
litres = [c for c in candidates if _LITRES_RANGE[0] <= c <= _LITRES_RANGE[1]]
totals = [c for c in candidates if _TOTAL_RANGE[0] <= c <= _TOTAL_RANGE[1]]
discounts = [0.0] + [
c for c in candidates if _DISCOUNT_RANGE[0] < c <= _DISCOUNT_RANGE[1]
]
best = float("inf")
for p in prices:
for litre in litres:
if litre == p:
continue
price_times_litres = p * litre
for t in totals:
if t == p or t == litre:
continue
for d in discounts:
residual = abs(price_times_litres - d - t)
if residual < best:
best = residual
return best
def is_plausible(residual: float) -> bool:
return residual <= TOLERANCE_EUR
+72 -11
View File
@@ -8,12 +8,22 @@ the output directory (01_gray, 02_bilateral, 03_final) so the effect of
each step can be inspected on its own before trusting the final image to 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)
+30 -14
View File
@@ -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)