2 Commits

Author SHA1 Message Date
ml ad2ef1689e Improve app UX (server setup, status, gallery, log) and allow
build / build (push) Failing after 3m0s
manual CI publishes

- Settings: prefill an editable example server URL, add a "Test
  connection" button
- Front page now shows the receipts list (Overview) with a column
  header and an empty-state message
- Add a connection status bar (polls /health) shown on every screen
- Capture: add "Choose from gallery" alongside the camera
- Add an in-app "App log" screen; ApiClient/CaptureViewModel/
  OverviewViewModel/ConnectionStatusViewModel now log key events,
  including the actual connection-failure reason (not just pass/fail)
- .gitea/workflows/build.yml: publish to dl.ladkau.de on a manual
  "Run workflow" dispatch too, not just push
- README.md: document the CI secrets (REGISTRY_USER/PASSWORD,
  DL_SFTP_KEY) that must be configured in Gitea, separate from the
  local registry.env
2026-09-29 19:33:39 +02:00
ml d5f4381169 Add a custom Android CI/release build image and packaging scripts
build / build (push) Failing after 0s
- build-image/: Dockerfile bundling JDK 17, the Android SDK, and
  Node.js (needed by Gitea's JS-based actions) - the app has no
  native code, so no NDK
- build-image.sh/upload-image.sh: extended to also build/push this
  image alongside the existing OCR pipeline tools image
- run-image.sh: runs `make android` inside the image against a live
  checkout, for a CI-equivalent local build
- Makefile: `make android` builds and versions a release APK into
  dist/, from the current git tag
- .gitea/workflows/build.yml: now pulls the custom image and just
  runs `make android`, replacing the ad-hoc Node/SDK install steps
- README.md: describes the pipeline, server, app, and build tooling
- registry.env.example: fix REGISTRY_IMAGE to be fully-qualified
  (an unqualified name was silently resolving pushes to Docker Hub
  instead of the configured registry)
2026-09-29 17:48:02 +02:00
23 changed files with 902 additions and 115 deletions
+21 -40
View File
@@ -1,10 +1,13 @@
name: build name: build
# Builds the Android app (android/) debug APK on every push/PR, plus # Builds the Android app (android/) debug APK on every push/PR, plus
# on-demand via the Gitea "Run workflow" button. Runs inside a public, # on-demand via the Gitea "Run workflow" button. Runs inside build-image
# prebuilt Android CI image (JDK + Android SDK/cmdline-tools bundled - # (see ../../build-image/Dockerfile, built/pushed via ../../build-image.sh
# fuelish's app has no native/NDK code, so it doesn't need a custom # and ../../upload-image.sh), which bundles the JDK, Android SDK, and
# builder image the way a project with native components would). # Node.js (needed by the JS-based actions below - see
# build-image/Dockerfile's own comment) so the runner itself doesn't need
# any of that installed. The Gradle build still needs network access at
# job runtime (Gradle/AGP/androidx dependency resolution).
on: on:
push: push:
pull_request: pull_request:
@@ -21,52 +24,28 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
# JDK + Android SDK cmdline-tools, no platform/build-tools installed - image: cr.ladkau.de/fuelish/android-builder:latest
# the "Install SDK components" step below fetches exactly what credentials:
# compileSdk in android/app/build.gradle.kts needs. Much smaller than username: ${{ secrets.REGISTRY_USER }}
# kitchen-sink alternatives (~0.7GB vs. several GB), which matters password: ${{ secrets.REGISTRY_PASSWORD }}
# since every job pulls it fresh.
image: thyrlian/android-sdk:10.0
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
# APK naming below uses `git rev-parse --short HEAD` - harmless # `make android`'s version comes from `git describe --tags` -
# with the default shallow clone, but fetch-depth: 0 keeps this # needs full history/tags, not actions/checkout's default
# consistent if that ever switches to `git describe --tags`. # shallow single-commit clone.
fetch-depth: 0 fetch-depth: 0
- name: Preflight - name: Preflight
run: | run: |
set -euo pipefail set -euo pipefail
command -v java >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: java not in PATH" >&2; exit 1; } command -v java >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: java not in PATH" >&2; exit 1; }
command -v make >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: make not in PATH" >&2; exit 1; }
[ -n "${ANDROID_HOME:-}" ] || { echo "PREFLIGHT FAIL: ANDROID_HOME not set" >&2; exit 1; } [ -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 - name: Build
working-directory: android run: make 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 - name: Upload build artifact
# v4 uses the newer @actions/artifact backend, which this Gitea # v4 uses the newer @actions/artifact backend, which this Gitea
@@ -78,9 +57,11 @@ jobs:
- name: Publish to dl.ladkau.de - name: Publish to dl.ladkau.de
# Uploads the APK over SFTP instead of using actions/upload-artifact # Uploads the APK over SFTP instead of using actions/upload-artifact
# (whose zip wrapping can't be disabled). Only runs on push so PR # (whose zip wrapping can't be disabled). Runs on push and on a
# builds don't publish. # manual "Run workflow" dispatch (the on-demand way to publish an
if: gitea.event_name == 'push' # update without pushing a commit) - excludes pull_request so PR
# builds from unreviewed branches don't publish.
if: gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch'
run: | run: |
set -euo pipefail set -euo pipefail
APK="$(ls dist/fuelish-*.apk)" APK="$(ls dist/fuelish-*.apk)"
+7
View File
@@ -18,6 +18,9 @@
__pycache__/ __pycache__/
*.pyc *.pyc
# Build output (see the root Makefile's `android` target, run-image.sh)
/dist/
# Android app build output/local config and generated resources (launcher # Android app build output/local config and generated resources (launcher
# icon + splash art - see android/app/build.gradle.kts's # icon + splash art - see android/app/build.gradle.kts's
# generateAppIcons task and android/scripts/gen_app_icons.py's own # generateAppIcons task and android/scripts/gen_app_icons.py's own
@@ -32,3 +35,7 @@ __pycache__/
/android/app/src/main/res/mipmap-xhdpi /android/app/src/main/res/mipmap-xhdpi
/android/app/src/main/res/mipmap-xxhdpi /android/app/src/main/res/mipmap-xxhdpi
/android/app/src/main/res/mipmap-xxxhdpi /android/app/src/main/res/mipmap-xxxhdpi
# Android Studio project files - per-developer, not shared
/android/.idea/
/android/gradle/gradle-daemon-jvm.properties
+45
View File
@@ -0,0 +1,45 @@
# Builds the Android app (android/) and packages a versioned release APK.
# The OCR pipeline itself (tooling-container/, 0-3_*.sh, test_batch.sh) is
# container-only and has no host build step, so this Makefile only covers
# the Android app.
ANDROID_DIR := android
ANDROID_APK := $(ANDROID_DIR)/app/build/outputs/apk/debug/app-debug.apk
DIST_DIR := dist
.PHONY: all android clean
all: android
# Builds the Android app (`./gradlew assembleDebug` in android/, via its
# own Gradle/AGP toolchain) and copies the resulting debug APK into
# dist/, named with the current git tag, or `make android VERSION=1.2.3`
# to override. Untagged builds (including every CI build, unless a
# release tag is pushed) get a 0.0.0-dev+<sha> placeholder version.
# Ships the auto-generated debug keystore, not a release signing config -
# fine for sideloading; a real release signing setup is a separate,
# not-yet-needed step. Requires the Android SDK (ANDROID_HOME set) - see
# build-image/Dockerfile for the exact version this is built/tested
# against, or run this inside it via ./run-image.sh.
android:
cd $(ANDROID_DIR) && ./gradlew assembleDebug
@mkdir -p $(DIST_DIR)
@V="$(VERSION)"; \
if [ -z "$$V" ]; then \
if TAG=$$(git describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null); then \
V=$${TAG#v}; \
else \
V="0.0.0-dev+$$(git rev-parse --short HEAD)"; \
echo "WARNING: HEAD is not on a vX.Y.Z tag - building placeholder version $$V (push a tag to drive a real release version)" >&2; \
fi; \
fi; \
case "$$V" in \
[0-9]*.[0-9]*.[0-9]*) ;; \
*) echo "PREFLIGHT FAIL: VERSION '$$V' is not a semantic version (expected X.Y.Z, optionally with a -pre+meta suffix)" >&2; exit 1;; \
esac; \
APK_NAME="fuelish-$$V.apk"; \
cp $(ANDROID_APK) "$(DIST_DIR)/$$APK_NAME"; \
echo "Wrote $(DIST_DIR)/$$APK_NAME"
clean:
rm -rf $(DIST_DIR) $(ANDROID_DIR)/build $(ANDROID_DIR)/app/build
+150 -1
View File
@@ -1,3 +1,152 @@
<p align="center">
<img src="logo.png" width="280" alt="fuelish logo">
</p>
# fuelish # fuelish
Extracting and organising fuel data from fuel receipts. Extracts structured data from photos of German fuel/petrol receipts —
petrol station, transaction number, date, time, price per litre, litres,
discount, and total price — and lets you review, correct, save, and
export it from an Android app.
The extraction pipeline is self-hosted (no cloud AI APIs): Tesseract OCR
plus a small local LLM (`qwen2.5:3b`, served by Ollama), all running in
containers. It's built to eventually run on a Raspberry Pi 4 cluster
under k3s; see [`AGENT.md`](AGENT.md) for the pipeline's architecture,
preprocessing rationale, and current extraction-accuracy notes.
## How it fits together
- **Pipeline** (`tooling-container/`): preprocess → OCR → LLM-structure a
receipt photo into JSON. Runs either as three independently-inspectable
host scripts (for development/testing) or as an HTTP job API (for the
app).
- **Server** (`docker-compose.yml`): the same pipeline, served over HTTP
(`POST /jobs`, `GET /jobs/{id}`) so the Android app can submit a photo
and poll real per-stage progress, backed by an Ollama container.
- **Android app** (`android/`): take a photo, watch it get processed,
correct any misread fields, save it locally, browse everything saved
so far, and export to CSV or `.ods` (matching the layout of the
ground-truth spreadsheets under `testdata/`).
- **CI** (`.gitea/workflows/build.yml`): builds the Android debug APK on
every push and publishes it to dl.ladkau.de, using a custom build image
(`build-image/`) so the runner needs nothing preinstalled.
## Repository layout
```
tooling-container/ Pipeline source (preprocess.py, ocr.py, extract.py,
server.py, compare_ground_truth.py) and the
Dockerfile/VERSION for the fuelish/tools image
both the host scripts and docker-compose.yml run.
0-3_*.sh Numbered host scripts driving one pipeline stage
each, for independent inspection during
development (0_run_ollama.sh starts Ollama).
test_batch.sh Runs the full pipeline over a directory of test
receipts and compares against its ground-truth
.ods - see AGENT.md's "Current state".
docker-compose.yml Runs the pipeline as a long-lived HTTP job API
(api + ollama services) for the Android app.
android/ The Android app: Kotlin + Jetpack Compose, Room
for local storage, OkHttp against the server API,
CSV/.ods export.
build-image/ Dockerfile for the Android CI/release build
environment (JDK + Android SDK, no NDK needed -
the app has no native code), used by CI and by
the build-image.sh/run-image.sh/upload-image.sh
scripts at the repo root (see "Building" below).
.gitea/workflows/ CI: builds the Android APK and publishes it to
dl.ladkau.de.
Makefile Root Makefile - packages a versioned release APK
(`make android`).
testdata/ Real receipt photos + ground-truth .ods files
(gitignored, not checked in - personal data).
```
See [`AGENT.md`](AGENT.md) for the pipeline's architecture, why the
current preprocessing approach was chosen over heavier alternatives, and
the latest batch-accuracy numbers and known failure modes.
## Building
**The pipeline image** (`tooling-container/`), used by the numbered host
scripts, `test_batch.sh`, and `docker-compose.yml`:
```bash
./build-image.sh # builds fuelish/tools and the Android build-image
# (below), and pulls ollama/ollama
```
**The server**, once the tools image is built:
```bash
docker compose up -d # starts ollama + api; api reachable at :8080
```
**The Android app** (`android/`) needs the Android SDK
(`ANDROID_HOME` set). Locally, without installing it yourself:
```bash
cp registry.env.example registry.env # fill in your registry credentials
./build-image.sh # builds the android-builder image (JDK + Android SDK)
./run-image.sh # runs `make android` inside it, against this
# checkout -> dist/fuelish-<version>.apk
```
With the SDK already installed, `make android` (or
`cd android && ./gradlew assembleDebug`) works directly on the host.
**CI** builds the same way — `.gitea/workflows/build.yml` runs
`make android` inside the `build-image/` image (pulled from the
registry, published via `./upload-image.sh` after `./build-image.sh`)
and publishes the APK to dl.ladkau.de on every push to the default
branch.
CI needs its own copy of credentials, separate from your local
`registry.env` (which only `build-image.sh`/`upload-image.sh`/
`run-image.sh` on your machine read - Gitea Actions never sees it). Add
these as **repo Settings → Actions → Secrets**:
- `REGISTRY_USER` / `REGISTRY_PASSWORD` - to pull the private
`build-image/` image (same values as `registry.env`)
- `DL_SFTP_KEY` - the private key for the SFTP publish step to
dl.ladkau.de
Without them, the workflow fails early (pulling the build image, or
publishing) rather than partway through the build.
## Running
**Inspect the pipeline stage by stage**, for development/debugging one
receipt at a time:
```bash
./0_run_ollama.sh # once, starts Ollama
./1_preprocess_image.sh -in photo.jpg -out scratch/
./2_ocr_image.sh -in scratch/03_final.png -out scratch/
./3_extract_data.sh -in scratch/ocr.txt -out scratch/ # -> extracted.json
```
**Batch-test against ground truth**:
```bash
./test_batch.sh -in testdata/<some-set> # needs a *.ods ground-truth
# file in that directory
```
**Serve it for the app**:
```bash
docker compose up -d
```
Then, in the Android app's Settings tab, point it at
`http://<this-host>:8080` (both HTTP-only - see the app's
`network_security_config.xml` - and LAN-only by design, no auth/TLS).
Take a photo, watch the 4-stage progress (upload → preprocess → OCR →
extract), correct anything the model got wrong, and save. The Receipts
tab lists everything saved so far and exports to CSV or `.ods`.
## License
MIT — see [`LICENSE`](LICENSE).
@@ -1,7 +1,9 @@
package de.ladkau.fuelish.data package de.ladkau.fuelish.data
import de.ladkau.fuelish.log.AppLog
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
@@ -13,7 +15,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
/** Talks to tooling-container/src/server.py's two endpoints. Every call /** Talks to tooling-container/src/server.py's endpoints. Every call
* takes the server's base URL explicitly (from [ConfigRepository]) rather * takes the server's base URL explicitly (from [ConfigRepository]) rather
* than storing it, since the user can change it from Settings mid-session. */ * than storing it, since the user can change it from Settings mid-session. */
class ApiClient(private val client: OkHttpClient = OkHttpClient()) { class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
@@ -23,7 +25,15 @@ class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
namingStrategy = JsonNamingStrategy.SnakeCase namingStrategy = JsonNamingStrategy.SnakeCase
} }
// Short timeouts for /health - it's polled every few seconds by the
// connection status bar and shouldn't hang waiting on a dead server.
private val healthClient = client.newBuilder()
.connectTimeout(3, TimeUnit.SECONDS)
.readTimeout(3, TimeUnit.SECONDS)
.build()
suspend fun submitImage(baseUrl: String, imageFile: File): String = withContext(Dispatchers.IO) { suspend fun submitImage(baseUrl: String, imageFile: File): String = withContext(Dispatchers.IO) {
try {
val body = MultipartBody.Builder() val body = MultipartBody.Builder()
.setType(MultipartBody.FORM) .setType(MultipartBody.FORM)
.addFormDataPart("image", imageFile.name, imageFile.asRequestBody("image/jpeg".toMediaType())) .addFormDataPart("image", imageFile.name, imageFile.asRequestBody("image/jpeg".toMediaType()))
@@ -33,6 +43,10 @@ class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
if (!response.isSuccessful) throw IOException("upload failed: HTTP ${response.code}") if (!response.isSuccessful) throw IOException("upload failed: HTTP ${response.code}")
val text = response.body?.string().orEmpty() val text = response.body?.string().orEmpty()
json.decodeFromString(CreateJobResponse.serializer(), text).jobId json.decodeFromString(CreateJobResponse.serializer(), text).jobId
}.also { jobId -> AppLog.log("Uploaded ${imageFile.name} to $baseUrl - job $jobId created") }
} catch (e: IOException) {
AppLog.log("Upload to $baseUrl failed: ${e.message}")
throw e
} }
} }
@@ -44,4 +58,29 @@ class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
json.decodeFromString(JobStatusResponse.serializer(), text) json.decodeFromString(JobStatusResponse.serializer(), text)
} }
} }
/** GET /health. Used by the settings "Test connection" button and the
* connection status bar - both surface [HealthCheckResult.Failed]'s
* message so a bad URL/unreachable server is diagnosable from the
* phone's App log screen alone, without needing adb logcat. */
suspend fun checkHealth(baseUrl: String): HealthCheckResult = withContext(Dispatchers.IO) {
if (baseUrl.isBlank()) return@withContext HealthCheckResult.Failed("no server URL configured")
try {
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/health").get().build()
healthClient.newCall(request).execute().use { response ->
if (response.isSuccessful) HealthCheckResult.Ok else HealthCheckResult.Failed("HTTP ${response.code}")
}
} catch (e: IOException) {
// e.message is usually the genuinely useful part (e.g. "Failed
// to connect to /10.0.0.21:8000" for a wrong port, "Unable to
// resolve host" for a bad address) - the exception class name
// alone (e.g. ConnectException) isn't specific enough on its own.
HealthCheckResult.Failed("${e.javaClass.simpleName}: ${e.message}")
}
}
}
sealed interface HealthCheckResult {
data object Ok : HealthCheckResult
data class Failed(val message: String) : HealthCheckResult
} }
@@ -0,0 +1,30 @@
package de.ladkau.fuelish.log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* A small in-memory, in-process log the app writes to at points a user
* would actually want to inspect when something goes wrong (server
* requests, connectivity changes, saves) - surfaced via the drawer's
* "App log" entry (ui/log/LogScreen.kt). Not persisted - it's a
* this-session diagnostic aid, not an audit trail.
*/
object AppLog {
data class Entry(val timestampMillis: Long, val message: String)
private const val MAX_ENTRIES = 500
private val _entries = MutableStateFlow<List<Entry>>(emptyList())
val entries: StateFlow<List<Entry>> = _entries.asStateFlow()
fun log(message: String) {
_entries.update { (it + Entry(System.currentTimeMillis(), message)).takeLast(MAX_ENTRIES) }
}
fun clear() {
_entries.value = emptyList()
}
}
@@ -0,0 +1,57 @@
package de.ladkau.fuelish.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.ladkau.fuelish.R
@Composable
fun ConnectionStatusBar(viewModel: ConnectionStatusViewModel = viewModel()) {
val status by viewModel.status.collectAsState()
val serverUrl by viewModel.serverUrl.collectAsState()
val color = when (status) {
ConnectionStatus.CONNECTED -> MaterialTheme.colorScheme.primary
ConnectionStatus.DISCONNECTED -> MaterialTheme.colorScheme.error
ConnectionStatus.NOT_CONFIGURED, ConnectionStatus.CHECKING -> MaterialTheme.colorScheme.outline
}
val text = when (status) {
ConnectionStatus.CONNECTED -> stringResource(R.string.status_connected, serverUrl)
ConnectionStatus.DISCONNECTED -> stringResource(R.string.status_disconnected, serverUrl)
ConnectionStatus.NOT_CONFIGURED -> stringResource(R.string.status_not_configured)
ConnectionStatus.CHECKING -> stringResource(R.string.status_checking)
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(color),
)
Text(text = text, style = MaterialTheme.typography.bodySmall)
}
}
@@ -0,0 +1,69 @@
package de.ladkau.fuelish.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import de.ladkau.fuelish.data.ApiClient
import de.ladkau.fuelish.data.ConfigRepository
import de.ladkau.fuelish.data.HealthCheckResult
import de.ladkau.fuelish.log.AppLog
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
private const val POLL_INTERVAL_MILLIS = 10_000L
enum class ConnectionStatus { CHECKING, CONNECTED, DISCONNECTED, NOT_CONFIGURED }
/** Scoped to the Activity (created once at [de.ladkau.fuelish.ui.FuelishApp]'s
* top level, not per-screen), so the status bar keeps polling regardless of
* which drawer destination is showing. */
class ConnectionStatusViewModel(application: Application) : AndroidViewModel(application) {
private val configRepository = ConfigRepository(application)
private val api = ApiClient()
private val _status = MutableStateFlow(ConnectionStatus.CHECKING)
val status: StateFlow<ConnectionStatus> = _status.asStateFlow()
private val _serverUrl = MutableStateFlow("")
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
init {
viewModelScope.launch {
// collectLatest: a settings change (new server URL) cancels
// whatever poll loop is in flight and restarts against the
// new URL, rather than continuing to poll the old one.
configRepository.config.collectLatest { config ->
_serverUrl.value = config.serverUrl
if (config.serverUrl.isBlank()) {
_status.value = ConnectionStatus.NOT_CONFIGURED
} else {
pollLoop(config.serverUrl)
}
}
}
}
private suspend fun pollLoop(baseUrl: String) {
var lastStable: ConnectionStatus? = null
while (true) {
_status.value = ConnectionStatus.CHECKING
val result = api.checkHealth(baseUrl)
val newStatus = if (result is HealthCheckResult.Ok) ConnectionStatus.CONNECTED else ConnectionStatus.DISCONNECTED
if (newStatus != lastStable) {
AppLog.log(
when (result) {
is HealthCheckResult.Ok -> "Connected to $baseUrl"
is HealthCheckResult.Failed -> "Cannot reach server at $baseUrl: ${result.message}"
},
)
lastStable = newStatus
}
_status.value = newStatus
delay(POLL_INTERVAL_MILLIS)
}
}
}
@@ -26,6 +26,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import de.ladkau.fuelish.R import de.ladkau.fuelish.R
import de.ladkau.fuelish.ui.capture.CaptureScreen import de.ladkau.fuelish.ui.capture.CaptureScreen
import de.ladkau.fuelish.ui.log.LogScreen
import de.ladkau.fuelish.ui.overview.OverviewScreen import de.ladkau.fuelish.ui.overview.OverviewScreen
import de.ladkau.fuelish.ui.settings.ConfigScreen import de.ladkau.fuelish.ui.settings.ConfigScreen
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -34,10 +35,12 @@ private object Destinations {
const val CAPTURE = "capture" const val CAPTURE = "capture"
const val OVERVIEW = "overview" const val OVERVIEW = "overview"
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val LOG = "log"
} }
/** Burger menu = ModalNavigationDrawer, leading to Scan/Receipts/Settings - /** Burger menu = ModalNavigationDrawer, leading to Receipts/Scan/Settings/App
* the app's single navigation host. */ * log - the app's single navigation host. Receipts is the front page (start
* destination) since that's what most opens of the app want to see. */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun FuelishApp() { fun FuelishApp() {
@@ -50,24 +53,30 @@ fun FuelishApp() {
drawerState = drawerState, drawerState = drawerState,
drawerContent = { drawerContent = {
ModalDrawerSheet { ModalDrawerSheet {
NavigationDrawerItem(
label = { Text(stringResource(R.string.nav_capture)) },
selected = currentRoute == Destinations.CAPTURE,
onClick = { navigateTo(navController, Destinations.CAPTURE); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp),
)
NavigationDrawerItem( NavigationDrawerItem(
label = { Text(stringResource(R.string.nav_overview)) }, label = { Text(stringResource(R.string.nav_overview)) },
selected = currentRoute == Destinations.OVERVIEW, selected = currentRoute == Destinations.OVERVIEW,
onClick = { navigateTo(navController, Destinations.OVERVIEW); scope.launch { drawerState.close() } }, onClick = { navigateTo(navController, Destinations.OVERVIEW); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp), modifier = Modifier.padding(horizontal = 12.dp),
) )
NavigationDrawerItem(
label = { Text(stringResource(R.string.nav_capture)) },
selected = currentRoute == Destinations.CAPTURE,
onClick = { navigateTo(navController, Destinations.CAPTURE); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp),
)
NavigationDrawerItem( NavigationDrawerItem(
label = { Text(stringResource(R.string.nav_settings)) }, label = { Text(stringResource(R.string.nav_settings)) },
selected = currentRoute == Destinations.SETTINGS, selected = currentRoute == Destinations.SETTINGS,
onClick = { navigateTo(navController, Destinations.SETTINGS); scope.launch { drawerState.close() } }, onClick = { navigateTo(navController, Destinations.SETTINGS); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp), modifier = Modifier.padding(horizontal = 12.dp),
) )
NavigationDrawerItem(
label = { Text(stringResource(R.string.nav_log)) },
selected = currentRoute == Destinations.LOG,
onClick = { navigateTo(navController, Destinations.LOG); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp),
)
} }
}, },
) { ) {
@@ -82,15 +91,17 @@ fun FuelishApp() {
}, },
) )
}, },
bottomBar = { ConnectionStatusBar() },
) { innerPadding -> ) { innerPadding ->
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Destinations.CAPTURE, startDestination = Destinations.OVERVIEW,
modifier = Modifier.padding(innerPadding), modifier = Modifier.padding(innerPadding),
) { ) {
composable(Destinations.CAPTURE) { CaptureScreen() }
composable(Destinations.OVERVIEW) { OverviewScreen() } composable(Destinations.OVERVIEW) { OverviewScreen() }
composable(Destinations.CAPTURE) { CaptureScreen() }
composable(Destinations.SETTINGS) { ConfigScreen() } composable(Destinations.SETTINGS) { ConfigScreen() }
composable(Destinations.LOG) { LogScreen() }
} }
} }
} }
@@ -1,9 +1,11 @@
package de.ladkau.fuelish.ui.capture package de.ladkau.fuelish.ui.capture
import android.Manifest import android.Manifest
import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -65,9 +67,17 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
if (granted) launchCamera() if (granted) launchCamera()
} }
val pickImageLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) {
val file = copyPickedImageToCache(context, uri)
if (file != null) viewModel.submit(file, uri)
}
}
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) { Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
when (val state = uiState) { when (val state = uiState) {
is CaptureUiState.Idle -> Button( is CaptureUiState.Idle -> Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = { onClick = {
val hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == val hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED PackageManager.PERMISSION_GRANTED
@@ -76,6 +86,14 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
) { ) {
Text(stringResource(R.string.capture_take_photo)) Text(stringResource(R.string.capture_take_photo))
} }
Button(
onClick = {
pickImageLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
},
) {
Text(stringResource(R.string.capture_choose_gallery))
}
}
is CaptureUiState.Uploading -> ProgressStage(stringResource(R.string.capture_stage_uploading), 0.2f) is CaptureUiState.Uploading -> ProgressStage(stringResource(R.string.capture_stage_uploading), 0.2f)
@@ -99,6 +117,22 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
} }
} }
/** Gallery picks come back as a content:// Uri, not a File - submit()
* needs a real File for the multipart upload, so copy it into the same
* cache dir the camera flow writes to. */
private fun copyPickedImageToCache(context: Context, uri: Uri): File? {
val dir = File(context.cacheDir, "captures").apply { mkdirs() }
val file = File(dir, "picked-${System.currentTimeMillis()}.jpg")
return try {
context.contentResolver.openInputStream(uri)?.use { input ->
file.outputStream().use { output -> input.copyTo(output) }
} ?: return null
file
} catch (e: java.io.IOException) {
null
}
}
@Composable @Composable
private fun ProgressStage(label: String, progress: Float) { private fun ProgressStage(label: String, progress: Float) {
Column( Column(
@@ -9,6 +9,7 @@ import de.ladkau.fuelish.data.ExtractedReceiptDto
import de.ladkau.fuelish.data.JobStatusResponse import de.ladkau.fuelish.data.JobStatusResponse
import de.ladkau.fuelish.data.Receipt import de.ladkau.fuelish.data.Receipt
import de.ladkau.fuelish.data.ReceiptRepository import de.ladkau.fuelish.data.ReceiptRepository
import de.ladkau.fuelish.log.AppLog
import java.io.File import java.io.File
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -49,18 +50,25 @@ class CaptureViewModel(application: Application) : AndroidViewModel(application)
} }
private suspend fun pollUntilDone(baseUrl: String, jobId: String) { private suspend fun pollUntilDone(baseUrl: String, jobId: String) {
var lastStage: String? = null
while (true) { while (true) {
val status = repository.pollJob(baseUrl, jobId) val status = repository.pollJob(baseUrl, jobId)
when (status.status) { when (status.status) {
JobStatusResponse.STATUS_DONE -> { JobStatusResponse.STATUS_DONE -> {
AppLog.log("Job $jobId done")
_uiState.value = CaptureUiState.Editing(status.result ?: ExtractedReceiptDto()) _uiState.value = CaptureUiState.Editing(status.result ?: ExtractedReceiptDto())
return return
} }
JobStatusResponse.STATUS_ERROR -> { JobStatusResponse.STATUS_ERROR -> {
AppLog.log("Job $jobId failed: ${status.error}")
_uiState.value = CaptureUiState.Error(status.error ?: "processing failed") _uiState.value = CaptureUiState.Error(status.error ?: "processing failed")
return return
} }
else -> { else -> {
if (status.status != lastStage) {
AppLog.log("Job $jobId: ${status.status}")
lastStage = status.status
}
_uiState.value = CaptureUiState.Processing(status.status) _uiState.value = CaptureUiState.Processing(status.status)
delay(POLL_INTERVAL_MILLIS) delay(POLL_INTERVAL_MILLIS)
} }
@@ -84,6 +92,7 @@ class CaptureViewModel(application: Application) : AndroidViewModel(application)
createdAtEpochMillis = System.currentTimeMillis(), createdAtEpochMillis = System.currentTimeMillis(),
), ),
) )
AppLog.log("Saved receipt (date=${draft.date}, total=${draft.totalPrice})")
pendingImageUri = null pendingImageUri = null
_uiState.value = CaptureUiState.Idle _uiState.value = CaptureUiState.Idle
} }
@@ -0,0 +1,63 @@
package de.ladkau.fuelish.ui.log
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import de.ladkau.fuelish.R
import de.ladkau.fuelish.log.AppLog
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun LogScreen() {
val entries by AppLog.entries.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
Button(
onClick = { AppLog.clear() },
modifier = Modifier.padding(16.dp),
) {
Text(stringResource(R.string.log_clear))
}
if (entries.isEmpty()) {
Text(stringResource(R.string.log_empty), modifier = Modifier.padding(16.dp))
} else {
// Newest first - the most recent event (usually what someone
// opened this screen to check) is what you want without scrolling.
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(entries.asReversed()) { entry ->
Text(
text = formatLogEntry(entry),
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
)
HorizontalDivider()
}
}
}
}
}
private val TIME_FORMAT = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
private fun formatLogEntry(entry: AppLog.Entry): String =
"${TIME_FORMAT.format(Date(entry.timestampMillis))} ${entry.message}"
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -15,6 +16,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -28,6 +30,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import de.ladkau.fuelish.R import de.ladkau.fuelish.R
import de.ladkau.fuelish.data.Receipt import de.ladkau.fuelish.data.Receipt
private val DELETE_COLUMN_WIDTH = 48.dp
@Composable @Composable
fun OverviewScreen(viewModel: OverviewViewModel = viewModel()) { fun OverviewScreen(viewModel: OverviewViewModel = viewModel()) {
val receipts by viewModel.receipts.collectAsState() val receipts by viewModel.receipts.collectAsState()
@@ -48,6 +52,9 @@ fun OverviewScreen(viewModel: OverviewViewModel = viewModel()) {
} }
} }
ReceiptTableHeader()
HorizontalDivider()
if (receipts.isEmpty()) { if (receipts.isEmpty()) {
Text( Text(
text = stringResource(R.string.overview_empty), text = stringResource(R.string.overview_empty),
@@ -65,20 +72,45 @@ fun OverviewScreen(viewModel: OverviewViewModel = viewModel()) {
} }
@Composable @Composable
private fun ReceiptRow(receipt: Receipt, onDelete: () -> Unit) { private fun ReceiptTableHeader() {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column { Text(
Text(text = receipt.date.orEmpty()) text = stringResource(R.string.overview_column_date),
Text(text = listOfNotNull(receipt.petrolStation, receipt.totalPrice?.let { "%.2f €".format(it) }).joinToString(" · ")) style = MaterialTheme.typography.labelLarge,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(R.string.overview_column_station),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.weight(2f),
)
Text(
text = stringResource(R.string.overview_column_total),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.weight(1f),
)
Row(modifier = Modifier.width(DELETE_COLUMN_WIDTH)) {}
} }
IconButton(onClick = onDelete) { }
Icon(Icons.Filled.Delete, contentDescription = null)
@Composable
private fun ReceiptRow(receipt: Receipt, onDelete: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = receipt.date.orEmpty(), modifier = Modifier.weight(1f))
Text(text = receipt.petrolStation.orEmpty(), modifier = Modifier.weight(2f))
Text(text = receipt.totalPrice?.let { "%.2f €".format(it) }.orEmpty(), modifier = Modifier.weight(1f))
IconButton(onClick = onDelete, modifier = Modifier.width(DELETE_COLUMN_WIDTH)) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.overview_delete))
} }
} }
} }
@@ -9,6 +9,7 @@ import de.ladkau.fuelish.data.Receipt
import de.ladkau.fuelish.data.ReceiptRepository import de.ladkau.fuelish.data.ReceiptRepository
import de.ladkau.fuelish.export.exportCsv as writeCsvRows import de.ladkau.fuelish.export.exportCsv as writeCsvRows
import de.ladkau.fuelish.export.exportOds as writeOdsRows import de.ladkau.fuelish.export.exportOds as writeOdsRows
import de.ladkau.fuelish.log.AppLog
import java.io.File import java.io.File
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -24,7 +25,10 @@ class OverviewViewModel(application: Application) : AndroidViewModel(application
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun delete(receipt: Receipt) { fun delete(receipt: Receipt) {
viewModelScope.launch { repository.delete(receipt) } viewModelScope.launch {
repository.delete(receipt)
AppLog.log("Deleted receipt (date=${receipt.date}, total=${receipt.totalPrice})")
}
} }
fun exportCsv(onReady: (Uri) -> Unit) = export("fuelish-receipts.csv", ::writeCsvRows, onReady) fun exportCsv(onReady: (Uri) -> Unit) = export("fuelish-receipts.csv", ::writeCsvRows, onReady)
@@ -40,6 +44,7 @@ class OverviewViewModel(application: Application) : AndroidViewModel(application
file.outputStream().use { write(receipts.value, it) } file.outputStream().use { write(receipts.value, it) }
FileProvider.getUriForFile(app, "${app.packageName}.fileprovider", file) FileProvider.getUriForFile(app, "${app.packageName}.fileprovider", file)
} }
AppLog.log("Exported ${receipts.value.size} receipt(s) to $fileName")
onReady(uri) onReady(uri)
} }
} }
@@ -2,9 +2,12 @@ package de.ladkau.fuelish.ui.settings
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -20,6 +23,7 @@ import de.ladkau.fuelish.R
fun ConfigScreen(viewModel: ConfigViewModel = viewModel()) { fun ConfigScreen(viewModel: ConfigViewModel = viewModel()) {
val config by viewModel.config.collectAsState() val config by viewModel.config.collectAsState()
val isDirty by viewModel.isDirty.collectAsState() val isDirty by viewModel.isDirty.collectAsState()
val testResult by viewModel.testResult.collectAsState()
Column( Column(
modifier = Modifier modifier = Modifier
@@ -31,12 +35,28 @@ fun ConfigScreen(viewModel: ConfigViewModel = viewModel()) {
value = config.serverUrl, value = config.serverUrl,
onValueChange = { viewModel.update { c -> c.copy(serverUrl = it) } }, onValueChange = { viewModel.update { c -> c.copy(serverUrl = it) } },
label = { Text(stringResource(R.string.settings_server_url)) }, label = { Text(stringResource(R.string.settings_server_url)) },
placeholder = { Text(stringResource(R.string.settings_server_url_hint)) },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { viewModel.save() }, enabled = isDirty) { Button(onClick = { viewModel.save() }, enabled = isDirty) {
Text(stringResource(R.string.capture_save)) Text(stringResource(R.string.capture_save))
} }
OutlinedButton(onClick = { viewModel.testConnection() }) {
Text(stringResource(R.string.settings_test_connection))
}
}
when (val result = testResult) {
ConnectionTestResult.Testing -> Text(stringResource(R.string.settings_test_testing))
ConnectionTestResult.Success -> Text(
stringResource(R.string.settings_test_success),
color = MaterialTheme.colorScheme.primary,
)
is ConnectionTestResult.Failure -> Text(
stringResource(R.string.settings_test_failure, result.message),
color = MaterialTheme.colorScheme.error,
)
null -> Unit
}
} }
} }
@@ -3,8 +3,12 @@ package de.ladkau.fuelish.ui.settings
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import de.ladkau.fuelish.R
import de.ladkau.fuelish.data.ApiClient
import de.ladkau.fuelish.data.AppConfig import de.ladkau.fuelish.data.AppConfig
import de.ladkau.fuelish.data.ConfigRepository import de.ladkau.fuelish.data.ConfigRepository
import de.ladkau.fuelish.data.HealthCheckResult
import de.ladkau.fuelish.log.AppLog
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -14,8 +18,15 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
sealed interface ConnectionTestResult {
data object Testing : ConnectionTestResult
data object Success : ConnectionTestResult
data class Failure(val message: String) : ConnectionTestResult
}
class ConfigViewModel(application: Application) : AndroidViewModel(application) { class ConfigViewModel(application: Application) : AndroidViewModel(application) {
private val configRepository = ConfigRepository(application) private val configRepository = ConfigRepository(application)
private val api = ApiClient()
// A local editable draft, seeded once from the persisted config below - // A local editable draft, seeded once from the persisted config below -
// deliberately not a live collection of configRepository.config, so // deliberately not a live collection of configRepository.config, so
@@ -29,16 +40,28 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
val isDirty: StateFlow<Boolean> = combine(_config, _savedConfig) { draft, saved -> draft != saved } val isDirty: StateFlow<Boolean> = combine(_config, _savedConfig) { draft, saved -> draft != saved }
.stateIn(viewModelScope, SharingStarted.Eagerly, false) .stateIn(viewModelScope, SharingStarted.Eagerly, false)
private val _testResult = MutableStateFlow<ConnectionTestResult?>(null)
val testResult: StateFlow<ConnectionTestResult?> = _testResult.asStateFlow()
init { init {
viewModelScope.launch { viewModelScope.launch {
val loaded = configRepository.config.first() val loaded = configRepository.config.first()
_config.value = loaded // Prefill an editable example rather than leaving the field
// blank with just a placeholder, so there's something to
// select-and-overwrite instead of typing a URL from scratch.
val seeded = if (loaded.serverUrl.isBlank()) {
loaded.copy(serverUrl = getApplication<Application>().getString(R.string.settings_server_url_example))
} else {
loaded
}
_config.value = seeded
_savedConfig.value = loaded _savedConfig.value = loaded
} }
} }
fun update(transform: (AppConfig) -> AppConfig) { fun update(transform: (AppConfig) -> AppConfig) {
_config.value = transform(_config.value) _config.value = transform(_config.value)
_testResult.value = null
} }
fun save() { fun save() {
@@ -48,4 +71,21 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
_savedConfig.value = toSave _savedConfig.value = toSave
} }
} }
fun testConnection() {
val url = _config.value.serverUrl
viewModelScope.launch {
_testResult.value = ConnectionTestResult.Testing
when (val result = api.checkHealth(url)) {
is HealthCheckResult.Ok -> {
AppLog.log("Test connection to $url succeeded")
_testResult.value = ConnectionTestResult.Success
}
is HealthCheckResult.Failed -> {
AppLog.log("Test connection to $url failed: ${result.message}")
_testResult.value = ConnectionTestResult.Failure(result.message)
}
}
}
}
} }
+20 -2
View File
@@ -5,8 +5,10 @@
<string name="nav_capture">Scan receipt</string> <string name="nav_capture">Scan receipt</string>
<string name="nav_overview">Receipts</string> <string name="nav_overview">Receipts</string>
<string name="nav_settings">Settings</string> <string name="nav_settings">Settings</string>
<string name="nav_log">App log</string>
<string name="capture_take_photo">Take photo</string> <string name="capture_take_photo">Take photo</string>
<string name="capture_choose_gallery">Choose from gallery</string>
<string name="capture_stage_uploading">Uploading…</string> <string name="capture_stage_uploading">Uploading…</string>
<string name="capture_stage_preprocessing">Preprocessing image…</string> <string name="capture_stage_preprocessing">Preprocessing image…</string>
<string name="capture_stage_ocr">Reading text (OCR)…</string> <string name="capture_stage_ocr">Reading text (OCR)…</string>
@@ -23,11 +25,27 @@
<string name="capture_field_discount">Discount</string> <string name="capture_field_discount">Discount</string>
<string name="capture_field_total_price">Total price</string> <string name="capture_field_total_price">Total price</string>
<string name="overview_empty">No receipts saved yet</string> <string name="overview_empty">No receipts have been scanned yet</string>
<string name="overview_column_date">Date</string>
<string name="overview_column_station">Station</string>
<string name="overview_column_total">Total</string>
<string name="overview_delete">Delete</string>
<string name="overview_export_csv">Export CSV</string> <string name="overview_export_csv">Export CSV</string>
<string name="overview_export_ods">Export ODS</string> <string name="overview_export_ods">Export ODS</string>
<string name="overview_export_done">Exported to %1$s</string> <string name="overview_export_done">Exported to %1$s</string>
<string name="settings_server_url">Server URL</string> <string name="settings_server_url">Server URL</string>
<string name="settings_server_url_hint">http://192.168.1.10:8080</string> <string name="settings_server_url_example">http://192.168.1.10:8080</string>
<string name="settings_test_connection">Test connection</string>
<string name="settings_test_testing">Testing…</string>
<string name="settings_test_success">Connected successfully</string>
<string name="settings_test_failure">Could not reach server: %1$s</string>
<string name="status_connected">Connected to %1$s</string>
<string name="status_disconnected">Cannot reach %1$s</string>
<string name="status_not_configured">No server configured - see Settings</string>
<string name="status_checking">Checking connection…</string>
<string name="log_clear">Clear log</string>
<string name="log_empty">Nothing logged yet</string>
</resources> </resources>
+27 -14
View File
@@ -1,9 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Builds the fuelish tools image (tooling-container/Dockerfile) locally, tagged with # Builds every image this repo's pipeline/CI needs: the fuelish tools
# tooling-container/VERSION and :latest, and pulls the other image the pipeline # image (tooling-container/Dockerfile) and the Android CI/release build
# needs (ollama/ollama, used by 0_run_ollama.sh) so all images are ready before # image (build-image/Dockerfile), each tagged from its own VERSION file,
# a pipeline run. Does not push - test it with the numbered pipeline scripts # plus pulls ollama/ollama (used by 0_run_ollama.sh). Does not push - test
# first, then publish with ./upload-image.sh. # the tools image with the numbered pipeline scripts and the
# android-builder image with ./run-image.sh, then publish both with
# ./upload-image.sh.
set -euo pipefail set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" cd "$(dirname "${BASH_SOURCE[0]}")"
@@ -14,26 +16,37 @@ 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}" TOOLS_IMAGE="${REGISTRY_IMAGE:-fuelish/tools}"
ANDROID_IMAGE="${REGISTRY_ANDROID_IMAGE:-cr.ladkau.de/fuelish/android-builder}"
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"
IMAGE="${REGISTRY_IMAGE:-$IMAGE}" TOOLS_IMAGE="${REGISTRY_IMAGE:-$TOOLS_IMAGE}"
ANDROID_IMAGE="${REGISTRY_ANDROID_IMAGE:-$ANDROID_IMAGE}"
fi fi
VERSION="$(<"$ROOT/tooling-container/VERSION")" TOOLS_VERSION="$(<"$ROOT/tooling-container/VERSION")"
[ -n "$VERSION" ] || fail "tooling-container/VERSION is empty" [ -n "$TOOLS_VERSION" ] || fail "tooling-container/VERSION is empty"
echo "== Building $IMAGE:$VERSION ==" ANDROID_VERSION="$(<"$ROOT/build-image/VERSION")"
[ -n "$ANDROID_VERSION" ] || fail "build-image/VERSION is empty"
echo "== Building $TOOLS_IMAGE:$TOOLS_VERSION =="
docker build \ docker build \
-f "$ROOT/tooling-container/Dockerfile" \ -f "$ROOT/tooling-container/Dockerfile" \
-t "$IMAGE:$VERSION" \ -t "$TOOLS_IMAGE:$TOOLS_VERSION" \
-t "$IMAGE:latest" \ -t "$TOOLS_IMAGE:latest" \
"$ROOT/tooling-container" "$ROOT/tooling-container"
echo "== Building $ANDROID_IMAGE:$ANDROID_VERSION =="
docker build \
-t "$ANDROID_IMAGE:$ANDROID_VERSION" \
-t "$ANDROID_IMAGE:latest" \
"$ROOT/build-image"
echo "== Pulling ollama/ollama (used by 0_run_ollama.sh) ==" echo "== Pulling ollama/ollama (used by 0_run_ollama.sh) =="
docker pull ollama/ollama docker pull ollama/ollama
echo "== Done ==" echo "== Done =="
echo "Image: $IMAGE:$VERSION (and :latest)" echo "Images: $TOOLS_IMAGE:$TOOLS_VERSION, $ANDROID_IMAGE:$ANDROID_VERSION (both also :latest)"
echo "Test it with the numbered pipeline scripts, then publish with ./upload-image.sh" echo "Test the tools image with the numbered pipeline scripts, the android-builder image with ./run-image.sh, then publish both with ./upload-image.sh"
+73
View File
@@ -0,0 +1,73 @@
# Build environment for the Android app (android/): JDK + Android SDK
# cmdline-tools, pinned to the versions validated on the maintainer's dev
# machine (matching android/app/build.gradle.kts's compileSdk). Used both
# by .gitea/workflows/build.yml's CI container and by ../run-image.sh for
# a local, CI-equivalent build. Rebuild with ../build-image.sh whenever a
# version below changes.
#
# fuelish's Android app has no native/NDK code, so unlike a build image
# that also needs an NDK/CMake toolchain, this is just a JDK + the SDK's
# own cmdline-tools/platform/build-tools.
#
# Does NOT contain any secrets - registry/SFTP credentials are injected
# at job runtime from Gitea Actions secrets, never baked into this image.
FROM ubuntu:24.04
ARG NODE_VERSION=24.16.0
ARG ANDROID_CMDLINE_TOOLS_VERSION=9862592
ARG ANDROID_PLATFORM_VERSION=36
ARG ANDROID_BUILD_TOOLS_VERSION=36.0.0
ENV DEBIAN_FRONTEND=noninteractive
# openjdk-17-jdk-headless: Gradle/AGP's own minimum JDK (see
# android/app/build.gradle.kts's compileOptions).
# make: the root Makefile's `android` target (run by both run-image.sh
# and .gitea/workflows/build.yml).
# python3/python3-pil: android/scripts/gen_app_icons.py's Pillow
# dependency (see android/app/build.gradle.kts's generateAppIcons
# preBuild task) - apt's python3-pil is used instead of pip/venv since
# nothing else in this image needs a Python environment.
# git/curl/ca-certificates/unzip/xz-utils: checkout and SDK/Node tool
# downloads (xz-utils: Node's release tarball below is .tar.xz).
# openssh-client: the build workflow's `sftp` publish to dl.ladkau.de.
RUN apt-get update && apt-get install -y --no-install-recommends \
openjdk-17-jdk-headless make python3 python3-pil \
git curl ca-certificates unzip xz-utils openssh-client \
&& rm -rf /var/lib/apt/lists/*
# Node.js: actions/checkout@v4 and actions/upload-artifact@v3 (used by
# .gitea/workflows/build.yml) are both JS actions - Gitea's act_runner
# execs them with whatever `node` is on this image's PATH rather than
# injecting one itself (https://github.com/nektos/act/issues/107), and
# this base image doesn't bring one.
RUN curl -sSL -o /tmp/node.tar.xz \
"https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \
&& tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 \
&& rm /tmp/node.tar.xz
# Android SDK: the cmdline-tools' own sdkmanager installs exactly the
# platform/build-tools version android/app/build.gradle.kts pins -
# resolved here so no `docker run` of this image needs network access to
# install anything before `./gradlew assembleDebug` works. The Gradle
# build itself still needs network access at build time (Gradle/AGP/
# androidx dependency resolution against Google's Maven + Maven Central) -
# android/gradlew resolves its own pinned Gradle version, no Gradle
# binary is baked into this image.
ENV ANDROID_HOME=/opt/android-sdk \
ANDROID_SDK_ROOT=/opt/android-sdk
ENV PATH=${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${PATH}
RUN mkdir -p "${ANDROID_HOME}/cmdline-tools" \
&& curl -sSL -o /tmp/cmdline-tools.zip \
"https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" \
&& unzip -q /tmp/cmdline-tools.zip -d "${ANDROID_HOME}/cmdline-tools" \
&& mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" \
&& rm /tmp/cmdline-tools.zip \
&& yes | sdkmanager --licenses >/dev/null \
&& sdkmanager --install \
"platform-tools" \
"platforms;android-${ANDROID_PLATFORM_VERSION}" \
"build-tools;${ANDROID_BUILD_TOOLS_VERSION}"
WORKDIR /workspace
+1
View File
@@ -0,0 +1 @@
0.1.0
+16 -6
View File
@@ -1,9 +1,19 @@
# Copy to registry.env (gitignored, never commit the real values) and fill # Copy to registry.env (gitignored, never commit the real values) and fill
# in your registry credentials. Used by build-image.sh, the numbered # in your registry credentials. Used by build-image.sh, upload-image.sh,
# pipeline scripts, and upload-image.sh. Only REGISTRY_IMAGE is needed for # run-image.sh, and the numbered pipeline scripts.
# local builds/runs; REGISTRY/REGISTRY_USER/REGISTRY_PASSWORD are only #
# needed to push with upload-image.sh. # REGISTRY_IMAGE (the OCR pipeline's tools image, tooling-container/) and
REGISTRY= # REGISTRY_ANDROID_IMAGE (the Android CI/release build image,
REGISTRY_IMAGE=fuelish/tools # build-image/) must both be fully-qualified with the registry host -
# upload-image.sh does `docker push "$REGISTRY_IMAGE:..."` directly, so
# an unqualified name (e.g. "fuelish/tools") silently resolves to Docker
# Hub instead of $REGISTRY and fails to push there ("insufficient_scope:
# authorization failed") even though `docker login "$REGISTRY"`
# succeeded. Adjust the host below if your registry isn't cr.ladkau.de.
# REGISTRY/REGISTRY_USER/REGISTRY_PASSWORD are only needed to push with
# upload-image.sh.
REGISTRY=cr.ladkau.de
REGISTRY_IMAGE=cr.ladkau.de/fuelish/tools
REGISTRY_ANDROID_IMAGE=cr.ladkau.de/fuelish/android-builder
REGISTRY_USER= REGISTRY_USER=
REGISTRY_PASSWORD= REGISTRY_PASSWORD=
Executable
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Runs `make android` inside the local android-builder image, mirroring
# what the Gitea Actions build workflow does - useful for testing
# build-image/ changes locally before pushing anything to a registry.
#
# Usage: ./run-image.sh [VERSION]
# VERSION is passed through to `make android` (see the Makefile's
# `android` target); omit it for its own git-tag-based default.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
ROOT="$(pwd)"
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
command -v docker >/dev/null 2>&1 \
|| fail "docker not found in PATH"
IMAGE="${REGISTRY_ANDROID_IMAGE:-cr.ladkau.de/fuelish/android-builder}"
if [ -f "$ROOT/registry.env" ]; then
# shellcheck disable=SC1091
source "$ROOT/registry.env"
IMAGE="${REGISTRY_ANDROID_IMAGE:-$IMAGE}"
fi
IMAGE_TAG="$(<"$ROOT/build-image/VERSION")"
[ -n "$IMAGE_TAG" ] || fail "build-image/VERSION is empty"
IMAGE="$IMAGE:$IMAGE_TAG"
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|| fail "$IMAGE not found locally - run ./build-image.sh first"
VERSION="${1:-${VERSION:-}}"
# CI always starts from a fresh checkout, but this script bind-mounts the
# live host repo - so build output left over from a previous local run
# (dist/, android/build, the generated app icon/splash resources) would
# make this run skip work a real fresh checkout wouldn't, silently hiding
# bugs that only show up in CI. Wipe them first so every run exercises a
# true from-scratch build, same as CI.
echo "== Cleaning generated build artifacts for a fresh build =="
rm -rf \
"$ROOT/dist" "$ROOT/android/build" "$ROOT/android/app/build" \
"$ROOT/android/app/src/main/res/drawable-nodpi" \
"$ROOT/android/app/src/main/res"/mipmap-mdpi "$ROOT/android/app/src/main/res"/mipmap-hdpi \
"$ROOT/android/app/src/main/res"/mipmap-xhdpi "$ROOT/android/app/src/main/res"/mipmap-xxhdpi \
"$ROOT/android/app/src/main/res"/mipmap-xxxhdpi
echo "== Running make android inside $IMAGE =="
# The container runs as root (needed for the SDK/Gradle caches baked into
# the image), so anything it writes into this bind mount - dist/,
# android/build, android/.gradle, etc. - would otherwise come back owned
# by root, leaving the host repo unusable without sudo. Chown everything
# back to the host user on exit, whether the build succeeds or fails.
docker run --rm \
-v "$ROOT:/workspace" \
-w /workspace \
-e VERSION="$VERSION" \
-e HOST_UID="$(id -u)" \
-e HOST_GID="$(id -g)" \
"$IMAGE" \
bash -c '
git config --global --add safe.directory /workspace
trap "chown -R \"$HOST_UID:$HOST_GID\" /workspace" EXIT
make android
'
echo "== Done - artifact in dist/ =="
ls -la "$ROOT/dist"
+23 -11
View File
@@ -1,8 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Pushes the fuelish tools image - already built locally with # Pushes every image ./build-image.sh built locally - the fuelish tools
# ./build-image.sh, and ideally verified with the numbered pipeline # image and the Android CI/release build image, each already verified
# scripts - to a container registry, so the k3s cluster (or other # (tools image with the numbered pipeline scripts, android-builder image
# machines) can pull it without rebuilding it. # with ./run-image.sh) - to a container registry, so the k3s cluster can
# pull the tools image and Gitea Actions can pull the android-builder
# image without rebuilding either.
set -euo pipefail set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" cd "$(dirname "${BASH_SOURCE[0]}")"
@@ -21,21 +23,31 @@ source "$ROOT/registry.env"
: "${REGISTRY:?registry.env must set REGISTRY}" : "${REGISTRY:?registry.env must set REGISTRY}"
: "${REGISTRY_IMAGE:?registry.env must set REGISTRY_IMAGE}" : "${REGISTRY_IMAGE:?registry.env must set REGISTRY_IMAGE}"
: "${REGISTRY_ANDROID_IMAGE:?registry.env must set REGISTRY_ANDROID_IMAGE}"
: "${REGISTRY_USER:?registry.env must set REGISTRY_USER}" : "${REGISTRY_USER:?registry.env must set REGISTRY_USER}"
: "${REGISTRY_PASSWORD:?registry.env must set REGISTRY_PASSWORD}" : "${REGISTRY_PASSWORD:?registry.env must set REGISTRY_PASSWORD}"
VERSION="$(<"$ROOT/tooling-container/VERSION")" TOOLS_VERSION="$(<"$ROOT/tooling-container/VERSION")"
[ -n "$VERSION" ] || fail "tooling-container/VERSION is empty" [ -n "$TOOLS_VERSION" ] || fail "tooling-container/VERSION is empty"
docker image inspect "$REGISTRY_IMAGE:$VERSION" >/dev/null 2>&1 \ ANDROID_VERSION="$(<"$ROOT/build-image/VERSION")"
|| fail "$REGISTRY_IMAGE:$VERSION not found locally - run ./build-image.sh first" [ -n "$ANDROID_VERSION" ] || fail "build-image/VERSION is empty"
docker image inspect "$REGISTRY_IMAGE:$TOOLS_VERSION" >/dev/null 2>&1 \
|| fail "$REGISTRY_IMAGE:$TOOLS_VERSION not found locally - run ./build-image.sh first"
docker image inspect "$REGISTRY_ANDROID_IMAGE:$ANDROID_VERSION" >/dev/null 2>&1 \
|| fail "$REGISTRY_ANDROID_IMAGE:$ANDROID_VERSION not found locally - run ./build-image.sh first"
echo "== Logging in to $REGISTRY ==" echo "== Logging in to $REGISTRY =="
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
echo "== Pushing $REGISTRY_IMAGE:$VERSION and :latest ==" echo "== Pushing $REGISTRY_IMAGE:$TOOLS_VERSION and :latest =="
docker push "$REGISTRY_IMAGE:$VERSION" docker push "$REGISTRY_IMAGE:$TOOLS_VERSION"
docker push "$REGISTRY_IMAGE:latest" docker push "$REGISTRY_IMAGE:latest"
echo "== Pushing $REGISTRY_ANDROID_IMAGE:$ANDROID_VERSION and :latest =="
docker push "$REGISTRY_ANDROID_IMAGE:$ANDROID_VERSION"
docker push "$REGISTRY_ANDROID_IMAGE:latest"
echo "== Done ==" echo "== Done =="
echo "Image: $REGISTRY_IMAGE:$VERSION" echo "Images: $REGISTRY_IMAGE:$TOOLS_VERSION, $REGISTRY_ANDROID_IMAGE:$ANDROID_VERSION"