Compare commits
3 Commits
v0.5.0
..
1324b2d727
| Author | SHA1 | Date | |
|---|---|---|---|
| 1324b2d727 | |||
| aae57d7a2b | |||
| f0ec353d3b |
@@ -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
@@ -17,18 +17,3 @@
|
|||||||
# instead of run through the container)
|
# instead of run through the container)
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.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
@@ -24,8 +24,8 @@ while [ $# -gt 0 ]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
CONTAINER=fuelish-ollama
|
CONTAINER=gastronomy-ollama
|
||||||
VOLUME=fuelish-ollama-data
|
VOLUME=gastronomy-ollama-data
|
||||||
PORT=11434
|
PORT=11434
|
||||||
|
|
||||||
if docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
|
if docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
|
||||||
|
|||||||
Executable → Regular
+1
-1
@@ -27,7 +27,7 @@ done
|
|||||||
|
|
||||||
command -v docker >/dev/null 2>&1 || fail "docker not found in PATH"
|
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
|
if [ -f "$ROOT/registry.env" ]; then
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source "$ROOT/registry.env"
|
source "$ROOT/registry.env"
|
||||||
|
|||||||
Executable → Regular
+1
-1
@@ -26,7 +26,7 @@ done
|
|||||||
|
|
||||||
command -v docker >/dev/null 2>&1 || fail "docker not found in PATH"
|
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
|
if [ -f "$ROOT/registry.env" ]; then
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source "$ROOT/registry.env"
|
source "$ROOT/registry.env"
|
||||||
|
|||||||
Executable → Regular
+1
-1
@@ -28,7 +28,7 @@ done
|
|||||||
|
|
||||||
command -v docker >/dev/null 2>&1 || fail "docker not found in PATH"
|
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
|
if [ -f "$ROOT/registry.env" ]; then
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source "$ROOT/registry.env"
|
source "$ROOT/registry.env"
|
||||||
|
|||||||
@@ -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,3 +1,3 @@
|
|||||||
# fuelish
|
# gastronomy
|
||||||
|
|
||||||
Extracting and organising fuel data from fuel receipts.
|
Extracting and organising fuel data from fuel receipts.
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
Vendored
-9
@@ -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(...);
|
|
||||||
}
|
|
||||||
@@ -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("&", "&")
|
|
||||||
.replace("<", "<")
|
|
||||||
.replace(">", ">")
|
|
||||||
.replace("\"", """)
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -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" }
|
|
||||||
BIN
Binary file not shown.
@@ -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
|
|
||||||
Vendored
-248
@@ -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" "$@"
|
|
||||||
Vendored
-82
@@ -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%
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
@@ -1,9 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Builds the fuelish tools image (tooling-container/Dockerfile) locally, tagged with
|
# Builds the gastronomy tools image (tooling-container/Dockerfile) locally, tagged with
|
||||||
# tooling-container/VERSION and :latest, and pulls the other image the pipeline
|
# tooling-container/VERSION and :latest. Does not push - test it with the numbered
|
||||||
# needs (ollama/ollama, used by 0_run_ollama.sh) so all images are ready before
|
# pipeline scripts first, then publish with ./upload-image.sh.
|
||||||
# a pipeline run. Does not push - test it with the numbered pipeline scripts
|
|
||||||
# first, then publish with ./upload-image.sh.
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
@@ -14,7 +12,7 @@ fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
|
|||||||
command -v docker >/dev/null 2>&1 \
|
command -v docker >/dev/null 2>&1 \
|
||||||
|| fail "docker not found in PATH"
|
|| fail "docker not found in PATH"
|
||||||
|
|
||||||
IMAGE="${REGISTRY_IMAGE:-fuelish/tools}"
|
IMAGE="${REGISTRY_IMAGE:-gastronomy/tools}"
|
||||||
if [ -f "$ROOT/registry.env" ]; then
|
if [ -f "$ROOT/registry.env" ]; then
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source "$ROOT/registry.env"
|
source "$ROOT/registry.env"
|
||||||
@@ -31,9 +29,6 @@ docker build \
|
|||||||
-t "$IMAGE:latest" \
|
-t "$IMAGE:latest" \
|
||||||
"$ROOT/tooling-container"
|
"$ROOT/tooling-container"
|
||||||
|
|
||||||
echo "== Pulling ollama/ollama (used by 0_run_ollama.sh) =="
|
|
||||||
docker pull ollama/ollama
|
|
||||||
|
|
||||||
echo "== Done =="
|
echo "== Done =="
|
||||||
echo "Image: $IMAGE:$VERSION (and :latest)"
|
echo "Image: $IMAGE:$VERSION (and :latest)"
|
||||||
echo "Test it with the numbered pipeline scripts, then publish with ./upload-image.sh"
|
echo "Test it with the numbered pipeline scripts, then publish with ./upload-image.sh"
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -4,6 +4,6 @@
|
|||||||
# local builds/runs; REGISTRY/REGISTRY_USER/REGISTRY_PASSWORD are only
|
# local builds/runs; REGISTRY/REGISTRY_USER/REGISTRY_PASSWORD are only
|
||||||
# needed to push with upload-image.sh.
|
# needed to push with upload-image.sh.
|
||||||
REGISTRY=
|
REGISTRY=
|
||||||
REGISTRY_IMAGE=fuelish/tools
|
REGISTRY_IMAGE=gastronomy/tools
|
||||||
REGISTRY_USER=
|
REGISTRY_USER=
|
||||||
REGISTRY_PASSWORD=
|
REGISTRY_PASSWORD=
|
||||||
|
|||||||
Executable → Regular
+1
-1
@@ -64,7 +64,7 @@ done
|
|||||||
echo
|
echo
|
||||||
echo "== Comparing against ground truth =="
|
echo "== Comparing against ground truth =="
|
||||||
|
|
||||||
IMAGE="${REGISTRY_IMAGE:-fuelish/tools}"
|
IMAGE="${REGISTRY_IMAGE:-gastronomy/tools}"
|
||||||
if [ -f "$ROOT/registry.env" ]; then
|
if [ -f "$ROOT/registry.env" ]; then
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source "$ROOT/registry.env"
|
source "$ROOT/registry.env"
|
||||||
|
|||||||
@@ -11,10 +11,7 @@ RUN pip install --no-cache-dir \
|
|||||||
pillow==11.0.0 \
|
pillow==11.0.0 \
|
||||||
pytesseract==0.3.13 \
|
pytesseract==0.3.13 \
|
||||||
requests==2.32.3 \
|
requests==2.32.3 \
|
||||||
odfpy==1.4.1 \
|
odfpy==1.4.1
|
||||||
fastapi==0.115.5 \
|
|
||||||
uvicorn[standard]==0.32.1 \
|
|
||||||
python-multipart==0.0.17
|
|
||||||
|
|
||||||
COPY src/ /opt/app/src/
|
COPY src/ /opt/app/src/
|
||||||
RUN chmod -R a+rX /opt/app/src
|
RUN chmod -R a+rX /opt/app/src
|
||||||
|
|||||||
@@ -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
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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
|
# ./build-image.sh, and ideally verified with the numbered pipeline
|
||||||
# scripts - to a container registry, so the k3s cluster (or other
|
# scripts - to a container registry, so the k3s cluster (or other
|
||||||
# machines) can pull it without rebuilding it.
|
# machines) can pull it without rebuilding it.
|
||||||
|
|||||||
Reference in New Issue
Block a user