3 Commits

Author SHA1 Message Date
matthias 1324b2d727 Merge project code with repository-initialized LICENSE/README 2026-09-28 19:31:33 +02:00
matthias aae57d7a2b Self-hosted, verifiable pipeline for extracting structured data (station,
date/time, price per litre, litres, discount, total) from German fuel
receipt photos: grayscale + mild bilateral filter + contrast stretch,
Tesseract OCR, then LLM structuring via a small local model (Qwen 2.5,
served by Ollama). Each step is independently runnable and inspectable
through numbered scripts against a shared tooling container.
2026-09-28 19:17:47 +02:00
matthias f0ec353d3b Initial commit 2026-09-28 16:37:00 +00:00
57 changed files with 14 additions and 2199 deletions
-95
View File
@@ -1,95 +0,0 @@
name: build
# Builds the Android app (android/) debug APK on every push/PR, plus
# on-demand via the Gitea "Run workflow" button. Runs inside a public,
# prebuilt Android CI image (JDK + Android SDK/cmdline-tools bundled -
# fuelish's app has no native/NDK code, so it doesn't need a custom
# builder image the way a project with native components would).
on:
push:
pull_request:
workflow_dispatch:
jobs:
build:
# Runner defaults `run:` steps to `sh`, which doesn't understand
# `set -o pipefail` used below - force bash explicitly.
defaults:
run:
shell: bash
# Must match a label your act_runner is registered with.
runs-on: ubuntu-latest
container:
# JDK + Android SDK cmdline-tools, no platform/build-tools installed -
# the "Install SDK components" step below fetches exactly what
# compileSdk in android/app/build.gradle.kts needs. Much smaller than
# kitchen-sink alternatives (~0.7GB vs. several GB), which matters
# since every job pulls it fresh.
image: thyrlian/android-sdk:10.0
steps:
- uses: actions/checkout@v4
with:
# APK naming below uses `git rev-parse --short HEAD` - harmless
# with the default shallow clone, but fetch-depth: 0 keeps this
# consistent if that ever switches to `git describe --tags`.
fetch-depth: 0
- name: Preflight
run: |
set -euo pipefail
command -v java >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: java not in PATH" >&2; exit 1; }
[ -n "${ANDROID_HOME:-}" ] || { echo "PREFLIGHT FAIL: ANDROID_HOME not set" >&2; exit 1; }
[ -x android/gradlew ] || { echo "PREFLIGHT FAIL: android/gradlew missing or not executable" >&2; exit 1; }
- name: Install SDK components
run: |
set -euo pipefail
# python3-pil: android/scripts/gen_app_icons.py's Pillow dependency
# (see android/app/build.gradle.kts's generateAppIcons preBuild task)
# - not bundled in this image.
apt-get update -qq
apt-get install -y --no-install-recommends python3-pil
yes | sdkmanager --licenses >/dev/null 2>&1 || true
# Keep in sync with android/app/build.gradle.kts's compileSdk.
sdkmanager "platforms;android-36" "build-tools;36.0.0"
- name: Build
working-directory: android
run: ./gradlew :app:assembleDebug --console=plain
- name: Package
# Renames the debug APK with the commit it was built from, so
# multiple builds in dist/ (and on dl.ladkau.de) don't collide.
run: |
set -euo pipefail
mkdir -p dist
sha="$(git rev-parse --short HEAD)"
cp android/app/build/outputs/apk/debug/app-debug.apk "dist/fuelish-$sha.apk"
- name: Upload build artifact
# v4 uses the newer @actions/artifact backend, which this Gitea
# instance's artifact storage doesn't support (GHESNotSupportedError) — v3 works.
uses: actions/upload-artifact@v3
with:
name: fuelish-android
path: dist/fuelish-*.apk
- name: Publish to dl.ladkau.de
# Uploads the APK over SFTP instead of using actions/upload-artifact
# (whose zip wrapping can't be disabled). Only runs on push so PR
# builds don't publish.
if: gitea.event_name == 'push'
run: |
set -euo pipefail
APK="$(ls dist/fuelish-*.apk)"
mkdir -p ~/.ssh
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
chmod 600 ~/.ssh/dl_sftp_key
sftp -i ~/.ssh/dl_sftp_key -P 2223 \
-o StrictHostKeyChecking=accept-new \
uploader@dl.ladkau.de <<EOF
-mkdir files/fuelish
put $APK files/fuelish/$(basename "$APK")
EOF
-15
View File
@@ -17,18 +17,3 @@
# instead of run through the container)
__pycache__/
*.pyc
# Android app build output/local config and generated resources (launcher
# icon + splash art - see android/app/build.gradle.kts's
# generateAppIcons task and android/scripts/gen_app_icons.py's own
# doc comment).
/android/local.properties
/android/.gradle
/android/build
/android/app/build
/android/app/src/main/res/drawable-nodpi
/android/app/src/main/res/mipmap-mdpi
/android/app/src/main/res/mipmap-hdpi
/android/app/src/main/res/mipmap-xhdpi
/android/app/src/main/res/mipmap-xxhdpi
/android/app/src/main/res/mipmap-xxxhdpi
Executable → Regular
+2 -2
View File
@@ -24,8 +24,8 @@ while [ $# -gt 0 ]; do
esac
done
CONTAINER=fuelish-ollama
VOLUME=fuelish-ollama-data
CONTAINER=gastronomy-ollama
VOLUME=gastronomy-ollama-data
PORT=11434
if docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
Executable → Regular
+1 -1
View File
@@ -27,7 +27,7 @@ done
command -v docker >/dev/null 2>&1 || fail "docker not found in PATH"
IMAGE="${REGISTRY_IMAGE:-fuelish/tools}"
IMAGE="${REGISTRY_IMAGE:-gastronomy/tools}"
if [ -f "$ROOT/registry.env" ]; then
# shellcheck disable=SC1091
source "$ROOT/registry.env"
Executable → Regular
+1 -1
View File
@@ -26,7 +26,7 @@ done
command -v docker >/dev/null 2>&1 || fail "docker not found in PATH"
IMAGE="${REGISTRY_IMAGE:-fuelish/tools}"
IMAGE="${REGISTRY_IMAGE:-gastronomy/tools}"
if [ -f "$ROOT/registry.env" ]; then
# shellcheck disable=SC1091
source "$ROOT/registry.env"
Executable → Regular
+1 -1
View File
@@ -28,7 +28,7 @@ done
command -v docker >/dev/null 2>&1 || fail "docker not found in PATH"
IMAGE="${REGISTRY_IMAGE:-fuelish/tools}"
IMAGE="${REGISTRY_IMAGE:-gastronomy/tools}"
if [ -f "$ROOT/registry.env" ]; then
# shellcheck disable=SC1091
source "$ROOT/registry.env"
-87
View File
@@ -1,87 +0,0 @@
# fuelish
Extracts structured data from photos of German fuel/petrol receipts:
petrol station, transaction number, date, time, price per litre, litres,
discount, and total price.
The extraction pipeline is self-hosted (no cloud AI APIs) and is built to
eventually run on a Raspberry Pi 4 cluster under k3s. Everything runs in
containers; there is no host Python environment to set up.
## Pipeline
Three verifiable, independently-inspectable steps, each a thin host
script that runs the actual work inside the `fuelish/tools` container:
1. `1_preprocess_image.sh -in <photo> -out <dir>` - grayscale conversion,
a mild bilateral filter, and a percentile contrast stretch. Writes
every intermediate stage (`01_gray.png`, `02_bilateral.png`,
`03_final.png`) so each step can be checked on its own.
2. `2_ocr_image.sh -in <dir>/03_final.png -out <dir>` - Tesseract OCR
(German), writes `ocr.txt`.
3. `3_extract_data.sh -in <dir>/ocr.txt -out <dir>` - sends the raw OCR
text to a small local LLM (`qwen2.5:3b`, served by Ollama) with a JSON
schema, writes `extracted.json`.
Preprocessing choice is evidence-based, not assumed: heavier processing
(strong denoising, CLAHE, unsharp masking) was tried and measured worse -
it amplifies the receipt's dotted security-background pattern into OCR
noise. The lighter bilateral+autocontrast approach in
`tooling-container/src/preprocess.py` is what survived testing.
## Supporting scripts
- `0_run_ollama.sh [-model <name>]` - starts Ollama as its own long-lived
Docker container (`fuelish-ollama`, model weights in the named
volume `fuelish-ollama-data`), pulling the model if not already
present. Deliberately separate from the tooling container: Ollama needs
to stay resident in memory with the model loaded, while the pipeline
steps are short-lived and stateless (one `docker run` per receipt).
`3_extract_data.sh` reaches it via `--network host` at
`http://127.0.0.1:11434` by default (override with `OLLAMA_URL`/
`OLLAMA_MODEL`).
- `build-image.sh` / `upload-image.sh` - build/push the
`tooling-container/` image, tagged from `tooling-container/VERSION`.
Image name defaults to `fuelish/tools`, overridable via
`registry.env` (copy from `registry.env.example`; gitignored).
- `test_batch.sh -in <test-data-dir>` - runs the full pipeline over every
`*.jpg` in a directory and compares results against that directory's
ground-truth spreadsheet (`*.ods`). For an input dir `<path>/<name>` it
creates sibling `<path>/<name>-scratch/` (intermediate step output per
receipt) and `<path>/<name>-result/` (final `extracted.json` per
receipt, plus `summary.txt`). Both are gitignored.
## Current state
Last full run against the 17-receipt ground-truth test set
(`testdata/2026_new/`, not checked in - see `.gitignore`):
- 15/17 receipts date-matched to ground truth (up from 11/17 with the
original plain-grayscale preprocessing)
- price_per_litre: 11/15 correct, litres: 9/15, discount: 11/15,
total_price: 12/15
Known remaining failure modes, not expected to be fixable by further
preprocessing tuning:
- OCR garbling on the compact "litres EUR/l price" line on some receipts
(the raw text sometimes lacks recoverable digits)
- Occasional year-digit misreads in the date causing a false date-match
failure
- Low-resolution source photos (well below the ~4000x3000 the good
results come from) OCR poorly regardless of preprocessing
The structuring prompt in `tooling-container/src/extract.py` uses
deliberately non-colliding example values. An earlier version used real
receipt values as worked examples, which caused the small model to
copy/anchor on those exact numbers instead of reading the actual OCR
text - a subtle failure mode worth remembering if the prompt is revised.
## Not yet built
Per the original idea (see git history / project notes): an Android app
to capture and confirm receipts, a backend server to store raw + confirmed
submissions, a simple authenticated web UI to query/filter/export
(CSV/JSON), and cross-checking total_price against
price_per_litre * litres - discount. None of that exists yet - this repo
currently covers only the extraction pipeline.
+1 -1
View File
@@ -1,3 +1,3 @@
# fuelish
# gastronomy
Extracting and organising fuel data from fuel receipts.
-79
View File
@@ -1,79 +0,0 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
}
android {
namespace = "de.ladkau.fuelish"
compileSdk = 36
defaultConfig {
applicationId = "de.ladkau.fuelish"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "0.0.0-dev"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
}
// Compiles with an actual JDK 17 toolchain (auto-provisioned - see
// settings.gradle.kts's foojay-resolver plugin) rather than whatever JDK
// launched Gradle, so it stays in sync with android.compileOptions above
// regardless of which JDK's on PATH.
kotlin {
jvmToolchain(17)
}
// Regenerates app/src/main/res/{mipmap-*,drawable-nodpi}/* from ../../logo.png
// (see scripts/gen_app_icons.py) - gitignored generated output, so a fresh
// checkout needs it produced before any resource-merge/asset-packaging task
// runs.
val generateAppIcons = tasks.register<Exec>("generateAppIcons") {
workingDir = rootDir
commandLine("python3", "scripts/gen_app_icons.py")
}
tasks.named("preBuild") {
dependsOn(generateAppIcons)
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.core)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.okhttp)
debugImplementation(libs.androidx.ui.tooling)
}
-9
View File
@@ -1,9 +0,0 @@
# kotlinx.serialization needs its generated serializer classes kept.
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclasseswithmembers class de.ladkau.fuelish.data.** {
*** Companion;
}
-keepclasseswithmembers class de.ladkau.fuelish.data.** {
kotlinx.serialization.KSerializer serializer(...);
}
-39
View File
@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:name=".FuelishApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.Fuelish.Splash">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Fuelish.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -1,5 +0,0 @@
package de.ladkau.fuelish
import android.app.Application
class FuelishApplication : Application()
@@ -1,22 +0,0 @@
package de.ladkau.fuelish
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import de.ladkau.fuelish.ui.FuelishApp
import de.ladkau.fuelish.ui.theme.FuelishTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
FuelishTheme {
FuelishApp()
}
}
}
}
@@ -1,47 +0,0 @@
package de.ladkau.fuelish.data
import java.io.File
import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
/** Talks to tooling-container/src/server.py's two endpoints. Every call
* takes the server's base URL explicitly (from [ConfigRepository]) rather
* than storing it, since the user can change it from Settings mid-session. */
class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
@OptIn(ExperimentalSerializationApi::class)
private val json = Json {
ignoreUnknownKeys = true
namingStrategy = JsonNamingStrategy.SnakeCase
}
suspend fun submitImage(baseUrl: String, imageFile: File): String = withContext(Dispatchers.IO) {
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("image", imageFile.name, imageFile.asRequestBody("image/jpeg".toMediaType()))
.build()
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs").post(body).build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("upload failed: HTTP ${response.code}")
val text = response.body?.string().orEmpty()
json.decodeFromString(CreateJobResponse.serializer(), text).jobId
}
}
suspend fun getJobStatus(baseUrl: String, jobId: String): JobStatusResponse = withContext(Dispatchers.IO) {
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs/$jobId").get().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("status check failed: HTTP ${response.code}")
val text = response.body?.string().orEmpty()
json.decodeFromString(JobStatusResponse.serializer(), text)
}
}
}
@@ -1,10 +0,0 @@
package de.ladkau.fuelish.data
/**
* Everything the app needs that isn't a saved receipt: just the fuelish
* server's base URL (e.g. "http://192.168.1.10:8080") right now.
* Persisted via Preferences DataStore, see [ConfigRepository].
*/
data class AppConfig(
val serverUrl: String = "",
)
@@ -1,29 +0,0 @@
package de.ladkau.fuelish.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.configDataStore: DataStore<Preferences> by preferencesDataStore(name = "fuelish_config")
/** Preferences DataStore-backed persistence for [AppConfig]. */
class ConfigRepository(private val context: Context) {
private object Keys {
val SERVER_URL = stringPreferencesKey("server_url")
}
val config: Flow<AppConfig> = context.configDataStore.data.map { prefs ->
AppConfig(serverUrl = prefs[Keys.SERVER_URL] ?: "")
}
suspend fun save(config: AppConfig) {
context.configDataStore.edit { prefs ->
prefs[Keys.SERVER_URL] = config.serverUrl
}
}
}
@@ -1,25 +0,0 @@
package de.ladkau.fuelish.data
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [Receipt::class], version = 1, exportSchema = false)
abstract class FuelishDatabase : RoomDatabase() {
abstract fun receiptDao(): ReceiptDao
companion object {
@Volatile
private var instance: FuelishDatabase? = null
fun get(context: Context): FuelishDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
FuelishDatabase::class.java,
"fuelish.db",
).build().also { instance = it }
}
}
}
@@ -1,37 +0,0 @@
package de.ladkau.fuelish.data
import kotlinx.serialization.Serializable
/** Mirrors tooling-container/src/server.py's POST /jobs response. */
@Serializable
data class CreateJobResponse(val jobId: String)
/** Mirrors tooling-container/src/server.py's GET /jobs/{id} response. */
@Serializable
data class JobStatusResponse(
val status: String,
val result: ExtractedReceiptDto? = null,
val error: String? = null,
) {
companion object {
const val STATUS_QUEUED = "queued"
const val STATUS_PREPROCESSING = "preprocessing"
const val STATUS_OCR = "ocr"
const val STATUS_EXTRACTING = "extracting"
const val STATUS_DONE = "done"
const val STATUS_ERROR = "error"
}
}
/** Mirrors tooling-container/src/extract.py's SCHEMA. */
@Serializable
data class ExtractedReceiptDto(
val petrolStation: String? = null,
val transactionNumber: String? = null,
val date: String? = null,
val time: String? = null,
val pricePerLitre: Double? = null,
val litres: Double? = null,
val discount: Double? = null,
val totalPrice: Double? = null,
)
@@ -1,27 +0,0 @@
package de.ladkau.fuelish.data
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* A confirmed receipt, as the user saved it after reviewing the server's
* extraction. This app is the system of record - the server is
* stateless processing only (see docker-compose.yml / server.py).
*/
@Entity(tableName = "receipts")
data class Receipt(
@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. */
val date: String?,
/** HH:MM:SS. */
val time: String?,
val pricePerLitre: Double?,
val litres: Double?,
val discount: Double?,
val totalPrice: Double?,
/** content:// or file:// URI of the captured photo, kept for reference - null if unavailable. */
val imageUri: String?,
val createdAtEpochMillis: Long,
)
@@ -1,26 +0,0 @@
package de.ladkau.fuelish.data
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface ReceiptDao {
@Query("SELECT * FROM receipts ORDER BY date DESC, createdAtEpochMillis DESC")
fun observeAll(): Flow<List<Receipt>>
@Query("SELECT * FROM receipts WHERE id = :id")
suspend fun getById(id: Long): Receipt?
@Insert
suspend fun insert(receipt: Receipt): Long
@Update
suspend fun update(receipt: Receipt)
@Delete
suspend fun delete(receipt: Receipt)
}
@@ -1,25 +0,0 @@
package de.ladkau.fuelish.data
import android.content.Context
import java.io.File
import kotlinx.coroutines.flow.Flow
/** Wraps the local Room store (the system of record for confirmed
* receipts) and the [ApiClient] (stateless per-receipt processing) behind
* one entry point for the ui layer. */
class ReceiptRepository(context: Context) {
private val dao = FuelishDatabase.get(context).receiptDao()
private val api = ApiClient()
val receipts: Flow<List<Receipt>> = dao.observeAll()
suspend fun submitImage(baseUrl: String, imageFile: File): String = api.submitImage(baseUrl, imageFile)
suspend fun pollJob(baseUrl: String, jobId: String): JobStatusResponse = api.getJobStatus(baseUrl, jobId)
suspend fun save(receipt: Receipt): Long = dao.insert(receipt)
suspend fun update(receipt: Receipt) = dao.update(receipt)
suspend fun delete(receipt: Receipt) = dao.delete(receipt)
}
@@ -1,28 +0,0 @@
package de.ladkau.fuelish.export
import de.ladkau.fuelish.data.Receipt
import java.io.OutputStream
/** ";"-delimited, not ",": our cells use a German decimal comma (see
* [toExportRow]), which would otherwise collide with a comma delimiter -
* ";" is also what German-locale Excel/LibreOffice expect by default. */
private const val DELIMITER = ";"
private val UTF8_BOM = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte())
fun exportCsv(receipts: List<Receipt>, out: OutputStream) {
out.write(UTF8_BOM)
out.write(csvLine(EXPORT_HEADERS).toByteArray(Charsets.UTF_8))
for (receipt in receipts) {
out.write(csvLine(receipt.toExportRow()).toByteArray(Charsets.UTF_8))
}
}
private fun csvLine(fields: List<String>): String =
fields.joinToString(DELIMITER) { escapeCsvField(it) } + "\r\n"
private fun escapeCsvField(field: String): String =
if (field.contains(DELIMITER) || field.contains('"') || field.contains('\n')) {
"\"" + field.replace("\"", "\"\"") + "\""
} else {
field
}
@@ -1,92 +0,0 @@
package de.ladkau.fuelish.export
import de.ladkau.fuelish.data.Receipt
import java.io.OutputStream
import java.util.zip.CRC32
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
private const val MIMETYPE = "application/vnd.oasis.opendocument.spreadsheet"
/**
* A minimal hand-rolled ODF spreadsheet writer - no dependency needed,
* since an .ods is just a zip of a few fixed XML files. Writes only what
* testdata/.../benzin_kosten.ods's own reader (compare_ground_truth.py,
* via odfpy) needs: one table, string-typed cells (matching how that
* ground-truth sheet itself stores formatted values - see
* [toExportRow]'s doc comment), no styles/formulas.
*/
fun exportOds(receipts: List<Receipt>, out: OutputStream) {
ZipOutputStream(out).use { zip ->
// Must be the first entry, stored uncompressed with no extra
// fields, so ODF-aware tools can sniff the file type without
// inflating - the format spec requires this.
val mimetypeBytes = MIMETYPE.toByteArray(Charsets.US_ASCII)
val mimetypeEntry = ZipEntry("mimetype").apply {
method = ZipEntry.STORED
size = mimetypeBytes.size.toLong()
compressedSize = mimetypeBytes.size.toLong()
crc = CRC32().apply { update(mimetypeBytes) }.value
}
zip.putNextEntry(mimetypeEntry)
zip.write(mimetypeBytes)
zip.closeEntry()
zip.putNextEntry(ZipEntry("META-INF/manifest.xml"))
zip.write(manifestXml().toByteArray(Charsets.UTF_8))
zip.closeEntry()
zip.putNextEntry(ZipEntry("content.xml"))
zip.write(contentXml(receipts).toByteArray(Charsets.UTF_8))
zip.closeEntry()
}
}
private fun manifestXml(): String = """
<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">
<manifest:file-entry manifest:full-path="/" manifest:version="1.2" manifest:media-type="$MIMETYPE"/>
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
</manifest:manifest>
""".trimIndent()
private fun contentXml(receipts: List<Receipt>): String {
val rows = buildString {
append(tableRow(EXPORT_HEADERS))
for (receipt in receipts) {
append(tableRow(receipt.toExportRow()))
}
}
return """
<?xml version="1.0" encoding="UTF-8"?>
<office:document-content
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
office:version="1.2">
<office:body>
<office:spreadsheet>
<table:table table:name="Receipts">
$rows
</table:table>
</office:spreadsheet>
</office:body>
</office:document-content>
""".trimIndent()
}
private fun tableRow(cells: List<String>): String = buildString {
append("<table:table-row>")
for (cell in cells) {
append("<table:table-cell office:value-type=\"string\"><text:p>")
append(escapeXml(cell))
append("</text:p></table:table-cell>")
}
append("</table:table-row>")
}
private fun escapeXml(text: String): String = text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
@@ -1,29 +0,0 @@
package de.ladkau.fuelish.export
import de.ladkau.fuelish.data.Receipt
import java.util.Locale
/**
* The first 6 columns mirror testdata/.../benzin_kosten.ods's layout
* exactly (date, time, price_per_litre, litres, discount, total_price -
* see tooling-container/src/compare_ground_truth.py's column order and
* its German-locale decimal-comma / "€"-suffix parsing, which this
* reproduces on the way out). petrol_station/transaction_number are
* appended as extra trailing columns - present in this app's data but
* not in the original ground-truth sheet.
*/
val EXPORT_HEADERS = listOf(
"Datum", "Uhrzeit", "Preis/Liter", "Liter", "Rabatt", "Gesamtpreis",
"Tankstelle", "Transaktionsnummer",
)
fun Receipt.toExportRow(): List<String> = listOf(
date.orEmpty(),
time.orEmpty(),
pricePerLitre?.let { String.format(Locale.GERMANY, "%.3f", it) }.orEmpty(),
litres?.let { String.format(Locale.GERMANY, "%.2f", it) }.orEmpty(),
discount?.let { String.format(Locale.GERMANY, "%.2f €", it) }.orEmpty(),
totalPrice?.let { String.format(Locale.GERMANY, "%.2f €", it) }.orEmpty(),
petrolStation.orEmpty(),
transactionNumber.orEmpty(),
)
@@ -1,105 +0,0 @@
package de.ladkau.fuelish.ui
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import de.ladkau.fuelish.R
import de.ladkau.fuelish.ui.capture.CaptureScreen
import de.ladkau.fuelish.ui.overview.OverviewScreen
import de.ladkau.fuelish.ui.settings.ConfigScreen
import kotlinx.coroutines.launch
private object Destinations {
const val CAPTURE = "capture"
const val OVERVIEW = "overview"
const val SETTINGS = "settings"
}
/** Burger menu = ModalNavigationDrawer, leading to Scan/Receipts/Settings -
* the app's single navigation host. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FuelishApp() {
val navController = rememberNavController()
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet {
NavigationDrawerItem(
label = { Text(stringResource(R.string.nav_capture)) },
selected = currentRoute == Destinations.CAPTURE,
onClick = { navigateTo(navController, Destinations.CAPTURE); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp),
)
NavigationDrawerItem(
label = { Text(stringResource(R.string.nav_overview)) },
selected = currentRoute == Destinations.OVERVIEW,
onClick = { navigateTo(navController, Destinations.OVERVIEW); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp),
)
NavigationDrawerItem(
label = { Text(stringResource(R.string.nav_settings)) },
selected = currentRoute == Destinations.SETTINGS,
onClick = { navigateTo(navController, Destinations.SETTINGS); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp),
)
}
},
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.app_name)) },
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.nav_settings))
}
},
)
},
) { innerPadding ->
NavHost(
navController = navController,
startDestination = Destinations.CAPTURE,
modifier = Modifier.padding(innerPadding),
) {
composable(Destinations.CAPTURE) { CaptureScreen() }
composable(Destinations.OVERVIEW) { OverviewScreen() }
composable(Destinations.SETTINGS) { ConfigScreen() }
}
}
}
}
private fun navigateTo(navController: NavHostController, route: String) {
navController.navigate(route) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
@@ -1,170 +0,0 @@
package de.ladkau.fuelish.ui.capture
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import de.ladkau.fuelish.R
import de.ladkau.fuelish.data.ExtractedReceiptDto
import de.ladkau.fuelish.data.JobStatusResponse
import java.io.File
@Composable
fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
var pendingFile by remember { mutableStateOf<File?>(null) }
var pendingUri by remember { mutableStateOf<Uri?>(null) }
val takePictureLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
val file = pendingFile
val uri = pendingUri
if (success && file != null && uri != null) {
viewModel.submit(file, uri)
}
}
fun launchCamera() {
val dir = File(context.cacheDir, "captures").apply { mkdirs() }
val file = File(dir, "capture-${System.currentTimeMillis()}.jpg")
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
pendingFile = file
pendingUri = uri
takePictureLauncher.launch(uri)
}
val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) launchCamera()
}
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
when (val state = uiState) {
is CaptureUiState.Idle -> Button(
onClick = {
val hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
if (hasPermission) launchCamera() else cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
},
) {
Text(stringResource(R.string.capture_take_photo))
}
is CaptureUiState.Uploading -> ProgressStage(stringResource(R.string.capture_stage_uploading), 0.2f)
is CaptureUiState.Processing -> {
val (labelRes, progress) = when (state.stage) {
JobStatusResponse.STATUS_PREPROCESSING -> R.string.capture_stage_preprocessing to 0.45f
JobStatusResponse.STATUS_OCR -> R.string.capture_stage_ocr to 0.7f
JobStatusResponse.STATUS_EXTRACTING -> R.string.capture_stage_extracting to 0.9f
else -> R.string.capture_stage_uploading to 0.2f
}
ProgressStage(stringResource(labelRes), progress)
}
is CaptureUiState.Editing -> EditForm(draft = state.draft, onSave = { viewModel.save(it) })
is CaptureUiState.Error -> Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.capture_stage_error, state.message))
Button(onClick = { viewModel.reset() }) { Text(stringResource(R.string.capture_retry)) }
}
}
}
}
@Composable
private fun ProgressStage(label: String, progress: Float) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(label)
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
}
}
@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,14 +0,0 @@
package de.ladkau.fuelish.ui.capture
import de.ladkau.fuelish.data.ExtractedReceiptDto
sealed interface CaptureUiState {
data object Idle : CaptureUiState
data object Uploading : CaptureUiState
/** [stage] is one of JobStatusResponse's STATUS_* constants (preprocessing/ocr/extracting). */
data class Processing(val stage: String) : CaptureUiState
data class Editing(val draft: ExtractedReceiptDto) : CaptureUiState
data class Error(val message: String) : CaptureUiState
}
@@ -1,96 +0,0 @@
package de.ladkau.fuelish.ui.capture
import android.app.Application
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import de.ladkau.fuelish.data.ConfigRepository
import de.ladkau.fuelish.data.ExtractedReceiptDto
import de.ladkau.fuelish.data.JobStatusResponse
import de.ladkau.fuelish.data.Receipt
import de.ladkau.fuelish.data.ReceiptRepository
import java.io.File
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
private const val POLL_INTERVAL_MILLIS = 1_500L
class CaptureViewModel(application: Application) : AndroidViewModel(application) {
private val repository = ReceiptRepository(application)
private val configRepository = ConfigRepository(application)
private val _uiState = MutableStateFlow<CaptureUiState>(CaptureUiState.Idle)
val uiState: StateFlow<CaptureUiState> = _uiState.asStateFlow()
// The captured photo's local URI, kept alongside the receipt once saved
// (see Receipt.imageUri) - set right before submit(), cleared on save()/reset().
private var pendingImageUri: Uri? = null
fun submit(imageFile: File, imageUri: Uri) {
pendingImageUri = imageUri
viewModelScope.launch {
_uiState.value = CaptureUiState.Uploading
try {
val baseUrl = configRepository.config.first().serverUrl
if (baseUrl.isBlank()) {
_uiState.value = CaptureUiState.Error("Set the server URL in Settings first")
return@launch
}
val jobId = repository.submitImage(baseUrl, imageFile)
pollUntilDone(baseUrl, jobId)
} catch (e: Exception) {
_uiState.value = CaptureUiState.Error(e.message ?: "unknown error")
}
}
}
private suspend fun pollUntilDone(baseUrl: String, jobId: String) {
while (true) {
val status = repository.pollJob(baseUrl, jobId)
when (status.status) {
JobStatusResponse.STATUS_DONE -> {
_uiState.value = CaptureUiState.Editing(status.result ?: ExtractedReceiptDto())
return
}
JobStatusResponse.STATUS_ERROR -> {
_uiState.value = CaptureUiState.Error(status.error ?: "processing failed")
return
}
else -> {
_uiState.value = CaptureUiState.Processing(status.status)
delay(POLL_INTERVAL_MILLIS)
}
}
}
}
fun save(draft: ExtractedReceiptDto) {
viewModelScope.launch {
repository.save(
Receipt(
petrolStation = draft.petrolStation,
transactionNumber = draft.transactionNumber,
date = draft.date,
time = draft.time,
pricePerLitre = draft.pricePerLitre,
litres = draft.litres,
discount = draft.discount,
totalPrice = draft.totalPrice,
imageUri = pendingImageUri?.toString(),
createdAtEpochMillis = System.currentTimeMillis(),
),
)
pendingImageUri = null
_uiState.value = CaptureUiState.Idle
}
}
fun reset() {
pendingImageUri = null
_uiState.value = CaptureUiState.Idle
}
}
@@ -1,93 +0,0 @@
package de.ladkau.fuelish.ui.overview
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.ladkau.fuelish.R
import de.ladkau.fuelish.data.Receipt
@Composable
fun OverviewScreen(viewModel: OverviewViewModel = viewModel()) {
val receipts by viewModel.receipts.collectAsState()
val context = LocalContext.current
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
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))
}
}
if (receipts.isEmpty()) {
Text(
text = stringResource(R.string.overview_empty),
modifier = Modifier.padding(16.dp),
)
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(receipts, key = { it.id }) { receipt ->
ReceiptRow(receipt = receipt, onDelete = { viewModel.delete(receipt) })
HorizontalDivider()
}
}
}
}
}
@Composable
private fun ReceiptRow(receipt: Receipt, onDelete: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(text = receipt.date.orEmpty())
Text(text = listOfNotNull(receipt.petrolStation, receipt.totalPrice?.let { "%.2f €".format(it) }).joinToString(" · "))
}
IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, contentDescription = null)
}
}
}
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,46 +0,0 @@
package de.ladkau.fuelish.ui.overview
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 java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class OverviewViewModel(application: Application) : AndroidViewModel(application) {
private val repository = ReceiptRepository(application)
val receipts: StateFlow<List<Receipt>> = repository.receipts
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun delete(receipt: Receipt) {
viewModelScope.launch { repository.delete(receipt) }
}
fun exportCsv(onReady: (Uri) -> Unit) = export("fuelish-receipts.csv", ::writeCsvRows, onReady)
fun exportOds(onReady: (Uri) -> Unit) = export("fuelish-receipts.ods", ::writeOdsRows, onReady)
private fun export(fileName: String, write: (List<Receipt>, java.io.OutputStream) -> Unit, onReady: (Uri) -> Unit) {
val app = getApplication<Application>()
viewModelScope.launch {
val uri = withContext(Dispatchers.IO) {
val dir = File(app.cacheDir, "exports").apply { mkdirs() }
val file = File(dir, fileName)
file.outputStream().use { write(receipts.value, it) }
FileProvider.getUriForFile(app, "${app.packageName}.fileprovider", file)
}
onReady(uri)
}
}
}
@@ -1,42 +0,0 @@
package de.ladkau.fuelish.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.ladkau.fuelish.R
@Composable
fun ConfigScreen(viewModel: ConfigViewModel = viewModel()) {
val config by viewModel.config.collectAsState()
val isDirty by viewModel.isDirty.collectAsState()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = config.serverUrl,
onValueChange = { viewModel.update { c -> c.copy(serverUrl = it) } },
label = { Text(stringResource(R.string.settings_server_url)) },
placeholder = { Text(stringResource(R.string.settings_server_url_hint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Button(onClick = { viewModel.save() }, enabled = isDirty) {
Text(stringResource(R.string.capture_save))
}
}
}
@@ -1,51 +0,0 @@
package de.ladkau.fuelish.ui.settings
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import de.ladkau.fuelish.data.AppConfig
import de.ladkau.fuelish.data.ConfigRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class ConfigViewModel(application: Application) : AndroidViewModel(application) {
private val configRepository = ConfigRepository(application)
// A local editable draft, seeded once from the persisted config below -
// deliberately not a live collection of configRepository.config, so
// in-progress edits aren't clobbered by DataStore's own emission of the
// write this same screen is about to make on save().
private val _config = MutableStateFlow(AppConfig())
val config: StateFlow<AppConfig> = _config.asStateFlow()
private val _savedConfig = MutableStateFlow(AppConfig())
val isDirty: StateFlow<Boolean> = combine(_config, _savedConfig) { draft, saved -> draft != saved }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
init {
viewModelScope.launch {
val loaded = configRepository.config.first()
_config.value = loaded
_savedConfig.value = loaded
}
}
fun update(transform: (AppConfig) -> AppConfig) {
_config.value = transform(_config.value)
}
fun save() {
viewModelScope.launch {
val toSave = _config.value
configRepository.save(toSave)
_savedConfig.value = toSave
}
}
}
@@ -1,30 +0,0 @@
package de.ladkau.fuelish.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val LightColors = lightColorScheme()
private val DarkColors = darkColorScheme()
@Composable
fun FuelishTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val context = LocalContext.current
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
darkTheme -> DarkColors
else -> LightColors
}
MaterialTheme(colorScheme = colorScheme, content = content)
}
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Matches the white background baked into logo.png, so the
generated launcher icon / splash screen sit on the same
background the source art was designed against. -->
<color name="ic_launcher_background">#FFFFFF</color>
<color name="splash_background">#FFFFFF</color>
</resources>
@@ -1,33 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">fuelish</string>
<string name="nav_capture">Scan receipt</string>
<string name="nav_overview">Receipts</string>
<string name="nav_settings">Settings</string>
<string name="capture_take_photo">Take photo</string>
<string name="capture_stage_uploading">Uploading…</string>
<string name="capture_stage_preprocessing">Preprocessing image…</string>
<string name="capture_stage_ocr">Reading text (OCR)…</string>
<string name="capture_stage_extracting">Extracting fields…</string>
<string name="capture_stage_error">Something went wrong: %1$s</string>
<string name="capture_retry">Retry</string>
<string name="capture_save">Save</string>
<string name="capture_field_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_time">Time (HH:MM:SS)</string>
<string name="capture_field_price_per_litre">Price per litre</string>
<string name="capture_field_litres">Litres</string>
<string name="capture_field_discount">Discount</string>
<string name="capture_field_total_price">Total price</string>
<string name="overview_empty">No receipts saved yet</string>
<string name="overview_export_csv">Export CSV</string>
<string name="overview_export_ods">Export ODS</string>
<string name="overview_export_done">Exported to %1$s</string>
<string name="settings_server_url">Server URL</string>
<string name="settings_server_url_hint">http://192.168.1.10:8080</string>
</resources>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Fuelish" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.Fuelish.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/splash_background</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="postSplashScreenTheme">@style/Theme.Fuelish</item>
</style>
</resources>
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Where the FileProvider (see AndroidManifest.xml) is allowed to serve
content:// URIs from. Captured receipt photos are written to the
cache dir by CaptureScreen before being handed to the camera intent
and then uploaded - see ui/capture/CaptureViewModel.kt. -->
<paths>
<cache-path name="captures" path="captures/" />
<!-- Exported CSV/ODS files (see export/CsvExporter.kt, OdsExporter.kt),
shared out via an ACTION_SEND intent - see
ui/overview/OverviewViewModel.kt. -->
<cache-path name="exports" path="exports/" />
</paths>
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- fuelish talks to a self-hosted, LAN-only server with no TLS (see
AGENT.md / the server plan - auth and TLS are an explicit v1
non-goal for a personal LAN tool). Android's network security config
has no CIDR-range matcher, only literal domains/IPs, so rather than
enumerate private-range addresses we allow cleartext globally; the
app never talks to anything but the server URL the user enters in
Settings. -->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
-6
View File
@@ -1,6 +0,0 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false
}
-11
View File
@@ -1,11 +0,0 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
ksp.useKSP2=true
# AGP 9's built-in Kotlin support rejects KSP's use of the kotlin.sourceSets
# DSL to register generated-source dirs (Room's annotation processor, via
# KSP) - this is AGP/KSP's own suggested suppression, not a workaround for
# anything we did. See https://developer.android.com/r/tools/built-in-kotlin
android.disallowKotlinSourceSets=false
-56
View File
@@ -1,56 +0,0 @@
[versions]
# Version-lookup tools (WebFetch/registry browsing) were unavailable while
# this file was written, so these are last-known-good pins rather than a
# freshly verified "latest stable" snapshot - if Gradle can't resolve one,
# bump it to whatever's current on Google's Maven / Maven Central.
#
# kotlin/ksp are pinned to 2.3.x rather than 2.2.20: androidx.room:
# room-common-jvm:2.8.5 declares a `{strictly 2.3.20}` constraint on
# kotlin-stdlib, which would otherwise silently resolve above what a
# 2.2.20 compiler/KSP understands. Keep these two in sync if Room is ever
# bumped further.
agp = "9.2.1"
kotlin = "2.3.20"
# KSP's versioning stopped mirroring Kotlin's exact patch after the 2.3.0
# line (no "2.3.20" KSP release exists) - 2.3.12 is the latest in the same
# 2.3.x line as kotlin above.
ksp = "2.3.12"
coreKtx = "1.17.0"
lifecycleRuntimeKtx = "2.9.4"
activityCompose = "1.13.0"
composeBom = "2026.06.01"
navigationCompose = "2.9.8"
datastorePreferences = "1.2.1"
kotlinxSerializationJson = "1.11.0"
kotlinxCoroutines = "1.11.0"
room = "2.8.5"
splashscreen = "1.0.1"
okhttp = "4.12.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
Binary file not shown.
-9
View File
@@ -1,9 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-248
View File
@@ -1,248 +0,0 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
-82
View File
@@ -1,82 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
-115
View File
@@ -1,115 +0,0 @@
#!/usr/bin/env python3
"""Generates the Android launcher icon and splash screen art from
../../logo.png (the repo's brand mark + "fuelish" wordmark).
Only the graphic mark (not the wordmark) works as a launcher/splash icon,
so this first isolates it: crop the upper portion of the source image
(where the mark lives) and auto-crop that to its actual non-transparent
bounding box, rather than relying on any hardcoded pixel offsets that
would break if logo.png is ever redrawn at a different resolution.
Produces:
- mipmap-<density>/ic_launcher_foreground.png - the mark centered, scaled
to the adaptive-icon safe zone (66% of a 108dp canvas), transparent
background - paired at runtime with @color/ic_launcher_background (see
mipmap-anydpi-v26/ic_launcher.xml, committed - a static reference, not
generated) for API 26+.
- mipmap-<density>/ic_launcher.png / ic_launcher_round.png - the same
mark flattened onto ic_launcher_background, for API <26 devices that
can't use the adaptive-icon XML at all.
- drawable-nodpi/splash_icon.png - the mark on a transparent background,
referenced by Theme.Fuelish.Splash's windowSplashScreenAnimatedIcon
(see values/themes.xml).
Generated output, not committed (see the repo's root .gitignore's
/android/app/src/main/res/{mipmap-*,drawable-nodpi} entries) - regenerate
by running this script directly or via Gradle's generateAppIcons task
(wired into app's preBuild, see ../app/build.gradle.kts). Requires
Pillow.
"""
import os
import sys
from PIL import Image
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
LOGO_PATH = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "logo.png"))
RES_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "app", "src", "main", "res"))
BACKGROUND_RGBA = (255, 255, 255, 255) # matches @color/ic_launcher_background
# (density qualifier, adaptive-icon canvas px, legacy launcher px)
DENSITIES = [
("mdpi", 108, 48),
("hdpi", 162, 72),
("xhdpi", 216, 96),
("xxhdpi", 324, 144),
("xxxhdpi", 432, 192),
]
# Adaptive icons render only the inner ~66% "safe zone" reliably across
# launchers that apply their own mask shape.
SAFE_ZONE_RATIO = 0.66
# Fraction of logo.png's height (from the top) that contains the graphic
# mark, before the "fuelish" wordmark begins.
MARK_REGION_HEIGHT_RATIO = 0.60
SPLASH_CANVAS_PX = 480
SPLASH_ICON_RATIO = 0.75
def extract_mark(logo: Image.Image) -> Image.Image:
width, height = logo.size
region = logo.crop((0, 0, width, round(height * MARK_REGION_HEIGHT_RATIO)))
bbox = region.getchannel("A").getbbox()
if bbox is None:
raise ValueError(f"{LOGO_PATH}: no non-transparent pixels found in the top "
f"{MARK_REGION_HEIGHT_RATIO:.0%} of the image")
mark = region.crop(bbox)
# Pad to square so downstream scaling doesn't distort the aspect ratio.
side = max(mark.width, mark.height)
square = Image.new("RGBA", (side, side), (0, 0, 0, 0))
square.paste(mark, ((side - mark.width) // 2, (side - mark.height) // 2), mark)
return square
def resized(image: Image.Image, target_px: int) -> Image.Image:
ratio = target_px / max(image.width, image.height)
size = (round(image.width * ratio), round(image.height * ratio))
return image.resize(size, Image.LANCZOS)
def centered_on(canvas_px: int, content: Image.Image, background: tuple) -> Image.Image:
canvas = Image.new("RGBA", (canvas_px, canvas_px), background)
offset = ((canvas_px - content.width) // 2, (canvas_px - content.height) // 2)
canvas.paste(content, offset, content)
return canvas
def main():
logo = Image.open(LOGO_PATH).convert("RGBA")
mark = extract_mark(logo)
for density, canvas_px, legacy_px in DENSITIES:
mipmap_dir = os.path.join(RES_DIR, f"mipmap-{density}")
os.makedirs(mipmap_dir, exist_ok=True)
foreground = centered_on(canvas_px, resized(mark, round(canvas_px * SAFE_ZONE_RATIO)), (0, 0, 0, 0))
foreground.save(os.path.join(mipmap_dir, "ic_launcher_foreground.png"))
legacy = centered_on(legacy_px, resized(mark, round(legacy_px * SAFE_ZONE_RATIO)), BACKGROUND_RGBA)
legacy.convert("RGB").save(os.path.join(mipmap_dir, "ic_launcher.png"))
legacy.convert("RGB").save(os.path.join(mipmap_dir, "ic_launcher_round.png"))
drawable_dir = os.path.join(RES_DIR, "drawable-nodpi")
os.makedirs(drawable_dir, exist_ok=True)
splash = centered_on(SPLASH_CANVAS_PX, resized(mark, round(SPLASH_CANVAS_PX * SPLASH_ICON_RATIO)), (0, 0, 0, 0))
splash.save(os.path.join(drawable_dir, "splash_icon.png"))
print(f"wrote launcher icons for {len(DENSITIES)} densities + splash icon to {RES_DIR}", file=sys.stderr)
if __name__ == "__main__":
main()
-26
View File
@@ -1,26 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
// Auto-provisions the JDK 17 toolchain app/build.gradle.kts requests (see
// its jvmToolchain(17) call) - this host's only JDKs otherwise are system
// JDK 11 (too old for AGP 9) and Android Studio's bundled JBR 25 (newer
// than Kotlin's compiler officially targets yet).
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "fuelish-android"
include(":app")
Executable → Regular
+4 -9
View File
@@ -1,9 +1,7 @@
#!/usr/bin/env bash
# Builds the fuelish tools image (tooling-container/Dockerfile) locally, tagged with
# tooling-container/VERSION and :latest, and pulls the other image the pipeline
# needs (ollama/ollama, used by 0_run_ollama.sh) so all images are ready before
# a pipeline run. Does not push - test it with the numbered pipeline scripts
# first, then publish with ./upload-image.sh.
# Builds the gastronomy tools image (tooling-container/Dockerfile) locally, tagged with
# tooling-container/VERSION and :latest. Does not push - test it with the numbered
# pipeline scripts first, then publish with ./upload-image.sh.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
@@ -14,7 +12,7 @@ fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
command -v docker >/dev/null 2>&1 \
|| fail "docker not found in PATH"
IMAGE="${REGISTRY_IMAGE:-fuelish/tools}"
IMAGE="${REGISTRY_IMAGE:-gastronomy/tools}"
if [ -f "$ROOT/registry.env" ]; then
# shellcheck disable=SC1091
source "$ROOT/registry.env"
@@ -31,9 +29,6 @@ docker build \
-t "$IMAGE:latest" \
"$ROOT/tooling-container"
echo "== Pulling ollama/ollama (used by 0_run_ollama.sh) =="
docker pull ollama/ollama
echo "== Done =="
echo "Image: $IMAGE:$VERSION (and :latest)"
echo "Test it with the numbered pipeline scripts, then publish with ./upload-image.sh"
-35
View File
@@ -1,35 +0,0 @@
# Runs the pipeline as a long-lived HTTP API for the Android app, on top
# of the same tooling-container image the numbered host scripts use.
# `docker compose up` alone is enough: the api service waits for Ollama
# and pulls the model itself (see tooling-container/src/server.py).
#
# The numbered host scripts (0_run_ollama.sh, 1/2/3_*.sh) and
# test_batch.sh are untouched and remain the way to inspect/test the
# pipeline stage by stage; this is the additive "serve it for real" path.
#
# Usage: docker compose up -d
# API reachable at http://<this-host>:8080 from any device on the LAN.
services:
ollama:
image: ollama/ollama
volumes:
- fuelish-ollama-data:/root/.ollama
restart: unless-stopped
api:
build:
context: ./tooling-container
restart: unless-stopped
depends_on:
- ollama
environment:
OLLAMA_URL: http://ollama:11434/api/chat
OLLAMA_BASE_URL: http://ollama:11434
OLLAMA_MODEL: qwen2.5:3b
command: ["uvicorn", "server:app", "--app-dir", "/opt/app/src", "--host", "0.0.0.0", "--port", "8000"]
ports:
- "8080:8000"
volumes:
fuelish-ollama-data:
name: fuelish-ollama-data
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

+1 -1
View File
@@ -4,6 +4,6 @@
# local builds/runs; REGISTRY/REGISTRY_USER/REGISTRY_PASSWORD are only
# needed to push with upload-image.sh.
REGISTRY=
REGISTRY_IMAGE=fuelish/tools
REGISTRY_IMAGE=gastronomy/tools
REGISTRY_USER=
REGISTRY_PASSWORD=
Executable → Regular
+1 -1
View File
@@ -64,7 +64,7 @@ done
echo
echo "== Comparing against ground truth =="
IMAGE="${REGISTRY_IMAGE:-fuelish/tools}"
IMAGE="${REGISTRY_IMAGE:-gastronomy/tools}"
if [ -f "$ROOT/registry.env" ]; then
# shellcheck disable=SC1091
source "$ROOT/registry.env"
+1 -4
View File
@@ -11,10 +11,7 @@ RUN pip install --no-cache-dir \
pillow==11.0.0 \
pytesseract==0.3.13 \
requests==2.32.3 \
odfpy==1.4.1 \
fastapi==0.115.5 \
uvicorn[standard]==0.32.1 \
python-multipart==0.0.17
odfpy==1.4.1
COPY src/ /opt/app/src/
RUN chmod -R a+rX /opt/app/src
-128
View File
@@ -1,128 +0,0 @@
#!/usr/bin/env python3
"""HTTP API that serves the pipeline (preprocess -> ocr -> structure) to
the Android app. Runs the same in-process functions the numbered host
scripts drive via `docker run` per stage, but skips the per-stage
container start-up since a live server has no need for that
inspectability - the host scripts remain the way to inspect each stage
independently during pipeline development.
Jobs are processed asynchronously so the app can show real per-stage
progress: POST /jobs returns immediately with a job id; GET /jobs/{id}
reports which of preprocessing/ocr/extracting is currently running, and
the final result once done. Job state lives in memory only - this is a
personal, LAN-only, single-instance server with no need for persistence
here (the Android app is the system of record for confirmed receipts).
"""
import os
import shutil
import tempfile
import threading
import time
import uuid
import requests
from fastapi import FastAPI, HTTPException, UploadFile
from fastapi.responses import JSONResponse
from 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_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:3b")
app = FastAPI(title="fuelish")
_jobs: dict[str, dict] = {}
_jobs_lock = threading.Lock()
def _set_job(job_id: str, **fields) -> None:
with _jobs_lock:
_jobs[job_id].update(fields)
def _ensure_model_pulled() -> None:
"""Best-effort: wait for Ollama to come up (compose starts `api` and
`ollama` together, with no ordering guarantee beyond container start)
and pull OLLAMA_MODEL if it isn't already present, so a bare `docker
compose up` is enough without a manual `ollama pull` step. Runs in a
background thread - jobs submitted before this finishes will simply
fail with a clear error from Ollama."""
tags = None
for _ in range(60):
try:
tags = requests.get(f"{OLLAMA_BASE_URL}/api/tags", timeout=5).json()
break
except requests.RequestException:
time.sleep(2)
if tags is None:
return
have = {m["name"] for m in tags.get("models", [])}
if OLLAMA_MODEL in have or any(m.startswith(OLLAMA_MODEL + ":") for m in have):
return
try:
requests.post(
f"{OLLAMA_BASE_URL}/api/pull",
json={"name": OLLAMA_MODEL},
timeout=1800,
)
except requests.RequestException:
pass
@app.on_event("startup")
def _on_startup() -> None:
threading.Thread(target=_ensure_model_pulled, daemon=True).start()
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
def _process_job(job_id: str, image_path: str, scratch_dir: str) -> None:
try:
_set_job(job_id, status="preprocessing")
final_path = preprocess(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")
result = structure(ocr_text)
_set_job(job_id, status="done", result=result)
except Exception as exc: # noqa: BLE001 - reported to the client, not swallowed
_set_job(job_id, status="error", error=str(exc))
finally:
shutil.rmtree(scratch_dir, ignore_errors=True)
@app.post("/jobs", status_code=201)
async def create_job(image: UploadFile) -> JSONResponse:
job_id = uuid.uuid4().hex
scratch_dir = tempfile.mkdtemp(prefix=f"fuelish-{job_id}-")
image_path = os.path.join(scratch_dir, "upload" + os.path.splitext(image.filename or "")[1])
with open(image_path, "wb") as f:
f.write(await image.read())
with _jobs_lock:
_jobs[job_id] = {"status": "queued", "result": None, "error": None, "_created": time.time()}
thread = threading.Thread(target=_process_job, args=(job_id, image_path, scratch_dir), daemon=True)
thread.start()
return JSONResponse({"job_id": job_id}, status_code=201)
@app.get("/jobs/{job_id}")
def get_job(job_id: str) -> dict:
with _jobs_lock:
job = _jobs.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="job not found")
return {"status": job["status"], "result": job["result"], "error": job["error"]}
Executable → Regular
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Pushes the fuelish tools image - already built locally with
# Pushes the gastronomy tools image - already built locally with
# ./build-image.sh, and ideally verified with the numbered pipeline
# scripts - to a container registry, so the k3s cluster (or other
# machines) can pull it without rebuilding it.