3 Commits

Author SHA1 Message Date
ml 9bb1844cf4 Combine scan+receipts into one flow, add plausibility check,
build / build (push) Failing after 2m52s
photo viewing, and record drill-down

- Overview is now the single front page (list + "+" FAB to scan)
  instead of a separate near-empty Scan tab; export moved from
  Overview into the drawer
- Drop petrol_station/transaction_number from storage, the edit
  form, and CSV/ODS export - not useful for a personal fuel-cost log
  (destructive Room migration, version 1->2 already shipped earlier)
- Add a live plausibility check (total_price vs. price_per_litre *
  litres - discount) shown as a green/red badge on the review screen
- Add a shared Details/Photo review screen (ReceiptReviewScreen),
  with pinch-to-zoom on the photo (new Coil dependency), reused both
  right after a scan and when drilling into an already-saved receipt
  from the list (ReceiptDetailScreen, with Update/Delete)
- Fix receipt photos not surviving an app restart: camera/gallery
  captures now write to internal storage (filesDir) instead of the
  cache dir, which the OS can clear independent of the database; the
  gallery picker was also saving the Photo Picker's own short-lived
  content:// Uri instead of a Uri onto our persisted copy
- App log: add a Copy button and selectable text
- Fix the connection status bar overlapping the system gesture nav
  bar under edge-to-edge
- Settings: prefill an editable example server URL, add a "Test
  connection" button; both it and the connection status bar now
  surface the actual failure reason, not just pass/fail
2026-10-03 06:44:22 +02:00
matthias 68e0cd8967 Adding testing experiments
build / build (push) Failing after 2m59s
2026-10-03 06:34:38 +02:00
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
31 changed files with 1197 additions and 211 deletions
+5 -3
View File
@@ -57,9 +57,11 @@ jobs:
- 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'
# (whose zip wrapping can't be disabled). Runs on push and on a
# manual "Run workflow" dispatch (the on-demand way to publish an
# 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: |
set -euo pipefail
APK="$(ls dist/fuelish-*.apk)"
+13
View File
@@ -102,6 +102,19 @@ registry, published via `./upload-image.sh` after `./build-image.sh`)
and publishes the APK to dl.ladkau.de on every push to the default
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
+1
View File
@@ -75,5 +75,6 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.okhttp)
implementation(libs.coil.compose)
debugImplementation(libs.androidx.ui.tooling)
}
@@ -1,7 +1,9 @@
package de.ladkau.fuelish.data
import de.ladkau.fuelish.log.AppLog
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
@@ -13,7 +15,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request
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
* than storing it, since the user can change it from Settings mid-session. */
class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
@@ -23,16 +25,28 @@ class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
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) {
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
try {
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
}.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)
}
}
/** 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
}
@@ -5,7 +5,12 @@ import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [Receipt::class], version = 1, exportSchema = false)
// version 2: dropped Receipt.petrolStation/transactionNumber. No migration
// path - fallbackToDestructiveMigration() wipes any locally-saved receipts
// on upgrade. Acceptable during active development (personal, single-user
// app, no real deployment yet); revisit with a real migration before that
// changes.
@Database(entities = [Receipt::class], version = 2, exportSchema = false)
abstract class FuelishDatabase : RoomDatabase() {
abstract fun receiptDao(): ReceiptDao
@@ -19,7 +24,10 @@ abstract class FuelishDatabase : RoomDatabase() {
context.applicationContext,
FuelishDatabase::class.java,
"fuelish.db",
).build().also { instance = it }
)
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
.also { instance = it }
}
}
}
@@ -23,11 +23,12 @@ data class JobStatusResponse(
}
}
/** Mirrors tooling-container/src/extract.py's SCHEMA. */
/** Mirrors the fields of tooling-container/src/extract.py's SCHEMA this
* app actually keeps - petrol_station/transaction_number come back too
* but are ignored (ignoreUnknownKeys=true in ApiClient's Json), since
* they're not useful for a personal fuel-cost log. */
@Serializable
data class ExtractedReceiptDto(
val petrolStation: String? = null,
val transactionNumber: String? = null,
val date: String? = null,
val time: String? = null,
val pricePerLitre: Double? = null,
@@ -7,12 +7,14 @@ import androidx.room.PrimaryKey
* A confirmed receipt, as the user saved it after reviewing the server's
* extraction. This app is the system of record - the server is
* stateless processing only (see docker-compose.yml / server.py).
*
* Deliberately doesn't keep petrol_station/transaction_number even though
* the server extracts them (see ExtractedReceiptDto) - not useful for a
* personal fuel-cost log, so there's no reason to store or show them.
*/
@Entity(tableName = "receipts")
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. */
@@ -0,0 +1,21 @@
package de.ladkau.fuelish.data
import kotlin.math.abs
/** Same tolerance tooling-container/src/compare_ground_truth.py's own
* close() uses for the equivalent ground-truth check - keeps "is this
* receipt's math consistent" judged the same way on both ends. */
private const val TOLERANCE_EUR = 0.05
enum class Plausibility { OK, DUBIOUS, UNKNOWN }
/** Cross-checks total_price against price_per_litre * litres - discount,
* the same arithmetic identity a real receipt must satisfy - a quick
* sanity check on whether a scan (or a hand-edit of one) is internally
* consistent. UNKNOWN (not DUBIOUS) when there isn't enough data to judge,
* e.g. mid-edit with a field temporarily blank. */
fun checkPlausibility(pricePerLitre: Double?, litres: Double?, discount: Double?, totalPrice: Double?): Plausibility {
if (pricePerLitre == null || litres == null || totalPrice == null) return Plausibility.UNKNOWN
val expected = pricePerLitre * litres - (discount ?: 0.0)
return if (abs(expected - totalPrice) <= TOLERANCE_EUR) Plausibility.OK else Plausibility.DUBIOUS
}
@@ -13,6 +13,8 @@ class ReceiptRepository(context: Context) {
val receipts: Flow<List<Receipt>> = dao.observeAll()
suspend fun getById(id: Long): Receipt? = dao.getById(id)
suspend fun submitImage(baseUrl: String, imageFile: File): String = api.submitImage(baseUrl, imageFile)
suspend fun pollJob(baseUrl: String, jobId: String): JobStatusResponse = api.getJobStatus(baseUrl, jobId)
@@ -4,18 +4,13 @@ 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.
* Mirrors 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).
*/
val EXPORT_HEADERS = listOf(
"Datum", "Uhrzeit", "Preis/Liter", "Liter", "Rabatt", "Gesamtpreis",
"Tankstelle", "Transaktionsnummer",
)
val EXPORT_HEADERS = listOf("Datum", "Uhrzeit", "Preis/Liter", "Liter", "Rabatt", "Gesamtpreis")
fun Receipt.toExportRow(): List<String> = listOf(
date.orEmpty(),
@@ -24,6 +19,4 @@ fun Receipt.toExportRow(): List<String> = listOf(
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(),
)
@@ -0,0 +1,30 @@
package de.ladkau.fuelish.log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* A small in-memory, in-process log the app writes to at points a user
* would actually want to inspect when something goes wrong (server
* requests, connectivity changes, saves) - surfaced via the drawer's
* "App log" entry (ui/log/LogScreen.kt). Not persisted - it's a
* this-session diagnostic aid, not an audit trail.
*/
object AppLog {
data class Entry(val timestampMillis: Long, val message: String)
private const val MAX_ENTRIES = 500
private val _entries = MutableStateFlow<List<Entry>>(emptyList())
val entries: StateFlow<List<Entry>> = _entries.asStateFlow()
fun log(message: String) {
_entries.update { (it + Entry(System.currentTimeMillis(), message)).takeLast(MAX_ENTRIES) }
}
fun clear() {
_entries.value = emptyList()
}
}
@@ -0,0 +1,65 @@
package de.ladkau.fuelish.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.ladkau.fuelish.R
@Composable
fun ConnectionStatusBar(viewModel: ConnectionStatusViewModel = viewModel()) {
val status by viewModel.status.collectAsState()
val serverUrl by viewModel.serverUrl.collectAsState()
val color = when (status) {
ConnectionStatus.CONNECTED -> MaterialTheme.colorScheme.primary
ConnectionStatus.DISCONNECTED -> MaterialTheme.colorScheme.error
ConnectionStatus.NOT_CONFIGURED, ConnectionStatus.CHECKING -> MaterialTheme.colorScheme.outline
}
val text = when (status) {
ConnectionStatus.CONNECTED -> stringResource(R.string.status_connected, serverUrl)
ConnectionStatus.DISCONNECTED -> stringResource(R.string.status_disconnected, serverUrl)
ConnectionStatus.NOT_CONFIGURED -> stringResource(R.string.status_not_configured)
ConnectionStatus.CHECKING -> stringResource(R.string.status_checking)
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
// Edge-to-edge (MainActivity.enableEdgeToEdge()) means this bar
// would otherwise draw under the system gesture nav bar at the
// bottom of the screen, overlapping its swipe area - push the
// content above it while the background still extends full-bleed.
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(color),
)
Text(text = text, style = MaterialTheme.typography.bodySmall)
}
}
@@ -0,0 +1,69 @@
package de.ladkau.fuelish.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import de.ladkau.fuelish.data.ApiClient
import de.ladkau.fuelish.data.ConfigRepository
import de.ladkau.fuelish.data.HealthCheckResult
import de.ladkau.fuelish.log.AppLog
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
private const val POLL_INTERVAL_MILLIS = 10_000L
enum class ConnectionStatus { CHECKING, CONNECTED, DISCONNECTED, NOT_CONFIGURED }
/** Scoped to the Activity (created once at [de.ladkau.fuelish.ui.FuelishApp]'s
* top level, not per-screen), so the status bar keeps polling regardless of
* which drawer destination is showing. */
class ConnectionStatusViewModel(application: Application) : AndroidViewModel(application) {
private val configRepository = ConfigRepository(application)
private val api = ApiClient()
private val _status = MutableStateFlow(ConnectionStatus.CHECKING)
val status: StateFlow<ConnectionStatus> = _status.asStateFlow()
private val _serverUrl = MutableStateFlow("")
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
init {
viewModelScope.launch {
// collectLatest: a settings change (new server URL) cancels
// whatever poll loop is in flight and restarts against the
// new URL, rather than continuing to poll the old one.
configRepository.config.collectLatest { config ->
_serverUrl.value = config.serverUrl
if (config.serverUrl.isBlank()) {
_status.value = ConnectionStatus.NOT_CONFIGURED
} else {
pollLoop(config.serverUrl)
}
}
}
}
private suspend fun pollLoop(baseUrl: String) {
var lastStable: ConnectionStatus? = null
while (true) {
_status.value = ConnectionStatus.CHECKING
val result = api.checkHealth(baseUrl)
val newStatus = if (result is HealthCheckResult.Ok) ConnectionStatus.CONNECTED else ConnectionStatus.DISCONNECTED
if (newStatus != lastStable) {
AppLog.log(
when (result) {
is HealthCheckResult.Ok -> "Connected to $baseUrl"
is HealthCheckResult.Failed -> "Cannot reach server at $baseUrl: ${result.message}"
},
)
lastStable = newStatus
}
_status.value = newStatus
delay(POLL_INTERVAL_MILLIS)
}
}
}
@@ -0,0 +1,44 @@
package de.ladkau.fuelish.ui
import android.app.Application
import android.net.Uri
import androidx.core.content.FileProvider
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import de.ladkau.fuelish.data.Receipt
import de.ladkau.fuelish.data.ReceiptRepository
import de.ladkau.fuelish.export.exportCsv as writeCsvRows
import de.ladkau.fuelish.export.exportOds as writeOdsRows
import de.ladkau.fuelish.log.AppLog
import java.io.File
import java.io.OutputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** Scoped at the app level (de.ladkau.fuelish.ui.FuelishApp), not per-screen,
* since the export actions live in the drawer and should work regardless
* of which destination is currently showing. */
class ExportViewModel(application: Application) : AndroidViewModel(application) {
private val repository = ReceiptRepository(application)
fun exportCsv(onReady: (Uri) -> Unit) = export("fuelish-receipts.csv", ::writeCsvRows, onReady)
fun exportOds(onReady: (Uri) -> Unit) = export("fuelish-receipts.ods", ::writeOdsRows, onReady)
private fun export(fileName: String, write: (List<Receipt>, OutputStream) -> Unit, onReady: (Uri) -> Unit) {
val app = getApplication<Application>()
viewModelScope.launch {
val receipts = repository.receipts.first()
val uri = withContext(Dispatchers.IO) {
val dir = File(app.cacheDir, "exports").apply { mkdirs() }
val file = File(dir, fileName)
file.outputStream().use { write(receipts, it) }
FileProvider.getUriForFile(app, "${app.packageName}.fileprovider", file)
}
AppLog.log("Exported ${receipts.size} receipt(s) to $fileName")
onReady(uri)
}
}
}
@@ -1,10 +1,15 @@
package de.ladkau.fuelish.ui
import android.content.Intent
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalDrawerSheet
@@ -17,45 +22,64 @@ import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
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 androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import de.ladkau.fuelish.R
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.receipt.ReceiptDetailScreen
import de.ladkau.fuelish.ui.settings.ConfigScreen
import kotlinx.coroutines.launch
private object Destinations {
const val CAPTURE = "capture"
const val OVERVIEW = "overview"
const val CAPTURE = "capture"
const val SETTINGS = "settings"
const val LOG = "log"
const val RECEIPT_DETAIL_ARG = "receiptId"
const val RECEIPT_DETAIL = "receipt_detail/{$RECEIPT_DETAIL_ARG}"
fun receiptDetail(id: Long) = "receipt_detail/$id"
// Reached via the drawer, so the top bar shows the hamburger menu.
// Anything else (Capture, receipt detail) is reached by pushing on top
// of one of these, so it gets a back arrow instead - see FuelishApp's
// navigationIcon below.
val DRAWER_DESTINATIONS = setOf(OVERVIEW, SETTINGS, LOG)
}
/** Burger menu = ModalNavigationDrawer, leading to Scan/Receipts/Settings -
* the app's single navigation host. */
/**
* Burger menu = ModalNavigationDrawer, leading to Receipts/Settings/App
* log plus the two export actions - Receipts is the front page (start
* destination) since that's what most opens of the app want to see, and
* scanning a new receipt is reached from its own "+" FAB rather than a
* separate always-visible tab (a bare scan screen with nothing else on it
* was pretty empty on its own).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FuelishApp() {
val navController = rememberNavController()
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
val context = LocalContext.current
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
val exportViewModel: ExportViewModel = viewModel()
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,
@@ -68,6 +92,31 @@ fun FuelishApp() {
onClick = { navigateTo(navController, Destinations.SETTINGS); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp),
)
NavigationDrawerItem(
label = { Text(stringResource(R.string.nav_log)) },
selected = currentRoute == Destinations.LOG,
onClick = { navigateTo(navController, Destinations.LOG); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp),
)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
NavigationDrawerItem(
label = { Text(stringResource(R.string.overview_export_csv)) },
selected = false,
onClick = {
scope.launch { drawerState.close() }
exportViewModel.exportCsv { uri -> shareFile(context, uri, "text/csv") }
},
modifier = Modifier.padding(horizontal = 12.dp),
)
NavigationDrawerItem(
label = { Text(stringResource(R.string.overview_export_ods)) },
selected = false,
onClick = {
scope.launch { drawerState.close() }
exportViewModel.exportOds { uri -> shareFile(context, uri, "application/vnd.oasis.opendocument.spreadsheet") }
},
modifier = Modifier.padding(horizontal = 12.dp),
)
}
},
) {
@@ -76,21 +125,47 @@ fun FuelishApp() {
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))
if (currentRoute in Destinations.DRAWER_DESTINATIONS) {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.nav_settings))
}
} else {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.nav_back))
}
}
},
)
},
floatingActionButton = {
if (currentRoute == Destinations.OVERVIEW) {
FloatingActionButton(onClick = { navController.navigate(Destinations.CAPTURE) }) {
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.nav_capture))
}
}
},
bottomBar = { ConnectionStatusBar() },
) { innerPadding ->
NavHost(
navController = navController,
startDestination = Destinations.CAPTURE,
startDestination = Destinations.OVERVIEW,
modifier = Modifier.padding(innerPadding),
) {
composable(Destinations.CAPTURE) { CaptureScreen() }
composable(Destinations.OVERVIEW) { OverviewScreen() }
composable(Destinations.OVERVIEW) {
OverviewScreen(onReceiptClick = { id -> navController.navigate(Destinations.receiptDetail(id)) })
}
composable(Destinations.CAPTURE) {
CaptureScreen(onSaved = { navController.popBackStack() })
}
composable(Destinations.SETTINGS) { ConfigScreen() }
composable(Destinations.LOG) { LogScreen() }
composable(
route = Destinations.RECEIPT_DETAIL,
arguments = listOf(navArgument(Destinations.RECEIPT_DETAIL_ARG) { type = NavType.LongType }),
) { backStackEntry ->
val id = backStackEntry.arguments?.getLong(Destinations.RECEIPT_DETAIL_ARG) ?: return@composable
ReceiptDetailScreen(receiptId = id, onDeleted = { navController.popBackStack() })
}
}
}
}
@@ -103,3 +178,12 @@ private fun navigateTo(navController: NavHostController, route: String) {
restoreState = true
}
}
private fun shareFile(context: android.content.Context, uri: android.net.Uri, mimeType: String) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = mimeType
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, null))
}
@@ -1,9 +1,11 @@
package de.ladkau.fuelish.ui.capture
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -11,11 +13,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.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
@@ -23,7 +22,6 @@ 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
@@ -32,12 +30,12 @@ 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 de.ladkau.fuelish.ui.receipt.ReceiptReviewScreen
import java.io.File
@Composable
fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
fun CaptureScreen(onSaved: () -> Unit, viewModel: CaptureViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
@@ -53,7 +51,7 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
}
fun launchCamera() {
val dir = File(context.cacheDir, "captures").apply { mkdirs() }
val dir = File(context.filesDir, "receipts").apply { mkdirs() }
val file = File(dir, "capture-${System.currentTimeMillis()}.jpg")
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
pendingFile = file
@@ -65,33 +63,67 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
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))
val pickImageLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { pickedUri ->
if (pickedUri != null) {
val file = copyPickedImageToCache(context, pickedUri)
if (file != null) {
// Must be a Uri onto our own persisted copy, NOT pickedUri
// itself - the system Photo Picker's content://media/picker/...
// Uri is a short-lived grant that stops resolving once this
// session ends (e.g. after the app is closed and reopened),
// regardless of whether the underlying bytes were copied
// somewhere durable.
val persistedUri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
viewModel.submit(file, persistedUri)
}
}
}
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
when (val state = uiState) {
is CaptureUiState.Idle -> Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
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))
}
Button(
onClick = {
pickImageLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
},
) {
Text(stringResource(R.string.capture_choose_gallery))
}
ProgressStage(stringResource(labelRes), progress)
}
}
is CaptureUiState.Editing -> EditForm(draft = state.draft, onSave = { viewModel.save(it) })
is CaptureUiState.Uploading -> Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
ProgressStage(stringResource(R.string.capture_stage_uploading), 0.2f)
}
is CaptureUiState.Error -> Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
is CaptureUiState.Processing -> Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
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 -> ReceiptReviewScreen(
initial = state.draft,
imageUri = state.imageUri,
saveLabel = stringResource(R.string.capture_save),
onSave = { edited -> viewModel.save(edited, onSaved) },
)
is CaptureUiState.Error -> Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.capture_stage_error, state.message))
Button(onClick = { viewModel.reset() }) { Text(stringResource(R.string.capture_retry)) }
}
@@ -99,6 +131,23 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
}
}
/** Gallery picks come back as a content:// Uri, not a File - submit()
* needs a real File for the multipart upload, so copy it into the same
* internal-storage dir the camera flow writes to (not cacheDir - see
* res/xml/file_paths.xml's own comment on why this must be durable). */
private fun copyPickedImageToCache(context: Context, uri: Uri): File? {
val dir = File(context.filesDir, "receipts").apply { mkdirs() }
val file = File(dir, "picked-${System.currentTimeMillis()}.jpg")
return try {
context.contentResolver.openInputStream(uri)?.use { input ->
file.outputStream().use { output -> input.copyTo(output) }
} ?: return null
file
} catch (e: java.io.IOException) {
null
}
}
@Composable
private fun ProgressStage(label: String, progress: Float) {
Column(
@@ -109,62 +158,3 @@ private fun ProgressStage(label: String, progress: Float) {
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
}
}
@Composable
private fun EditForm(draft: ExtractedReceiptDto, onSave: (ExtractedReceiptDto) -> Unit) {
var petrolStation by remember { mutableStateOf(draft.petrolStation.orEmpty()) }
var transactionNumber by remember { mutableStateOf(draft.transactionNumber.orEmpty()) }
var date by remember { mutableStateOf(draft.date.orEmpty()) }
var time by remember { mutableStateOf(draft.time.orEmpty()) }
var pricePerLitre by remember { mutableStateOf(draft.pricePerLitre?.toString().orEmpty()) }
var litres by remember { mutableStateOf(draft.litres?.toString().orEmpty()) }
var discount by remember { mutableStateOf(draft.discount?.toString().orEmpty()) }
var totalPrice by remember { mutableStateOf(draft.totalPrice?.toString().orEmpty()) }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
LabeledField(R.string.capture_field_petrol_station, petrolStation) { petrolStation = it }
LabeledField(R.string.capture_field_transaction_number, transactionNumber) { transactionNumber = it }
LabeledField(R.string.capture_field_date, date) { date = it }
LabeledField(R.string.capture_field_time, time) { time = it }
LabeledField(R.string.capture_field_price_per_litre, pricePerLitre) { pricePerLitre = it }
LabeledField(R.string.capture_field_litres, litres) { litres = it }
LabeledField(R.string.capture_field_discount, discount) { discount = it }
LabeledField(R.string.capture_field_total_price, totalPrice) { totalPrice = it }
Button(
onClick = {
onSave(
ExtractedReceiptDto(
petrolStation = petrolStation.ifBlank { null },
transactionNumber = transactionNumber.ifBlank { null },
date = date.ifBlank { null },
time = time.ifBlank { null },
pricePerLitre = pricePerLitre.toDoubleOrNull(),
litres = litres.toDoubleOrNull(),
discount = discount.toDoubleOrNull(),
totalPrice = totalPrice.toDoubleOrNull(),
),
)
},
modifier = Modifier.align(Alignment.End),
) {
Text(stringResource(R.string.capture_save))
}
}
}
@Composable
private fun LabeledField(labelRes: Int, value: String, onValueChange: (String) -> Unit) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(stringResource(labelRes)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
@@ -1,5 +1,6 @@
package de.ladkau.fuelish.ui.capture
import android.net.Uri
import de.ladkau.fuelish.data.ExtractedReceiptDto
sealed interface CaptureUiState {
@@ -9,6 +10,6 @@ sealed interface 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 Editing(val draft: ExtractedReceiptDto, val imageUri: Uri?) : CaptureUiState
data class Error(val message: String) : CaptureUiState
}
@@ -9,6 +9,7 @@ import de.ladkau.fuelish.data.ExtractedReceiptDto
import de.ladkau.fuelish.data.JobStatusResponse
import de.ladkau.fuelish.data.Receipt
import de.ladkau.fuelish.data.ReceiptRepository
import de.ladkau.fuelish.log.AppLog
import java.io.File
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -49,18 +50,25 @@ class CaptureViewModel(application: Application) : AndroidViewModel(application)
}
private suspend fun pollUntilDone(baseUrl: String, jobId: String) {
var lastStage: String? = null
while (true) {
val status = repository.pollJob(baseUrl, jobId)
when (status.status) {
JobStatusResponse.STATUS_DONE -> {
_uiState.value = CaptureUiState.Editing(status.result ?: ExtractedReceiptDto())
AppLog.log("Job $jobId done")
_uiState.value = CaptureUiState.Editing(status.result ?: ExtractedReceiptDto(), pendingImageUri)
return
}
JobStatusResponse.STATUS_ERROR -> {
AppLog.log("Job $jobId failed: ${status.error}")
_uiState.value = CaptureUiState.Error(status.error ?: "processing failed")
return
}
else -> {
if (status.status != lastStage) {
AppLog.log("Job $jobId: ${status.status}")
lastStage = status.status
}
_uiState.value = CaptureUiState.Processing(status.status)
delay(POLL_INTERVAL_MILLIS)
}
@@ -68,12 +76,10 @@ class CaptureViewModel(application: Application) : AndroidViewModel(application)
}
}
fun save(draft: ExtractedReceiptDto) {
fun save(draft: ExtractedReceiptDto, onSaved: () -> Unit) {
viewModelScope.launch {
repository.save(
Receipt(
petrolStation = draft.petrolStation,
transactionNumber = draft.transactionNumber,
date = draft.date,
time = draft.time,
pricePerLitre = draft.pricePerLitre,
@@ -84,8 +90,10 @@ class CaptureViewModel(application: Application) : AndroidViewModel(application)
createdAtEpochMillis = System.currentTimeMillis(),
),
)
AppLog.log("Saved receipt (date=${draft.date}, total=${draft.totalPrice})")
pendingImageUri = null
_uiState.value = CaptureUiState.Idle
onSaved()
}
}
@@ -0,0 +1,82 @@
package de.ladkau.fuelish.ui.log
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import de.ladkau.fuelish.R
import de.ladkau.fuelish.log.AppLog
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun LogScreen() {
val entries by AppLog.entries.collectAsState()
val clipboard = LocalClipboardManager.current
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Button(
onClick = {
clipboard.setText(AnnotatedString(entries.joinToString("\n") { formatLogEntry(it) }))
},
) {
Text(stringResource(R.string.log_copy))
}
Button(onClick = { AppLog.clear() }) {
Text(stringResource(R.string.log_clear))
}
}
if (entries.isEmpty()) {
Text(stringResource(R.string.log_empty), modifier = Modifier.padding(16.dp))
} else {
// Newest first - the most recent event (usually what someone
// opened this screen to check) is what you want without scrolling.
// SelectionContainer: lets text be selected/copied by hand too,
// in addition to the "copy everything" button above.
SelectionContainer {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(entries.asReversed()) { entry ->
Text(
text = formatLogEntry(entry),
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
)
HorizontalDivider()
}
}
}
}
}
}
private val TIME_FORMAT = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
private fun formatLogEntry(entry: AppLog.Entry): String =
"${TIME_FORMAT.format(Date(entry.timestampMillis))} ${entry.message}"
@@ -1,52 +1,41 @@
package de.ladkau.fuelish.ui.overview
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.clickable
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.layout.width
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.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.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
private val DELETE_COLUMN_WIDTH = 48.dp
@Composable
fun OverviewScreen(viewModel: OverviewViewModel = viewModel()) {
fun OverviewScreen(onReceiptClick: (Long) -> Unit, 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))
}
}
ReceiptTableHeader()
HorizontalDivider()
if (receipts.isEmpty()) {
Text(
@@ -56,7 +45,11 @@ fun OverviewScreen(viewModel: OverviewViewModel = viewModel()) {
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(receipts, key = { it.id }) { receipt ->
ReceiptRow(receipt = receipt, onDelete = { viewModel.delete(receipt) })
ReceiptRow(
receipt = receipt,
onClick = { onReceiptClick(receipt.id) },
onDelete = { viewModel.delete(receipt) },
)
HorizontalDivider()
}
}
@@ -65,29 +58,46 @@ fun OverviewScreen(viewModel: OverviewViewModel = viewModel()) {
}
@Composable
private fun ReceiptRow(receipt: Receipt, onDelete: () -> Unit) {
private fun ReceiptTableHeader() {
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)
}
Text(
text = stringResource(R.string.overview_column_date),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(R.string.overview_column_litres),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(R.string.overview_column_total),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.weight(1f),
)
Row(modifier = Modifier.width(DELETE_COLUMN_WIDTH)) {}
}
}
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)
@Composable
private fun ReceiptRow(receipt: Receipt, onClick: () -> Unit, onDelete: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = receipt.date.orEmpty(), modifier = Modifier.weight(1f))
Text(text = receipt.litres?.let { "%.2f l".format(it) }.orEmpty(), modifier = Modifier.weight(1f))
Text(text = receipt.totalPrice?.let { "%.2f €".format(it) }.orEmpty(), modifier = Modifier.weight(1f))
IconButton(onClick = onDelete, modifier = Modifier.width(DELETE_COLUMN_WIDTH)) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.overview_delete))
}
}
context.startActivity(Intent.createChooser(intent, null))
}
@@ -1,21 +1,15 @@
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 de.ladkau.fuelish.log.AppLog
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)
@@ -24,23 +18,9 @@ class OverviewViewModel(application: Application) : AndroidViewModel(application
.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)
repository.delete(receipt)
AppLog.log("Deleted receipt (date=${receipt.date}, total=${receipt.totalPrice})")
}
}
}
@@ -0,0 +1,61 @@
package de.ladkau.fuelish.ui.receipt
import android.app.Application
import android.net.Uri
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import de.ladkau.fuelish.R
import de.ladkau.fuelish.data.ExtractedReceiptDto
/** Drill-down from the overview list into one saved receipt - the same
* Details/Photo UI as the post-scan review (ui/capture/CaptureScreen.kt),
* but editing an existing DB row (Update) instead of inserting a new one,
* plus a Delete action. */
@Composable
fun ReceiptDetailScreen(receiptId: Long, onDeleted: () -> Unit) {
val application = LocalContext.current.applicationContext as Application
val viewModel: ReceiptDetailViewModel = viewModel(
factory = viewModelFactory { initializer { ReceiptDetailViewModel(application, receiptId) } },
)
val receipt by viewModel.receipt.collectAsState()
val deleted by viewModel.deleted.collectAsState()
LaunchedEffect(deleted) {
if (deleted) onDeleted()
}
val current = receipt
if (current == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return
}
ReceiptReviewScreen(
initial = ExtractedReceiptDto(
date = current.date,
time = current.time,
pricePerLitre = current.pricePerLitre,
litres = current.litres,
discount = current.discount,
totalPrice = current.totalPrice,
),
imageUri = current.imageUri?.let { Uri.parse(it) },
saveLabel = stringResource(R.string.receipt_update),
onSave = { edited -> viewModel.update(edited) },
onDelete = { viewModel.delete() },
)
}
@@ -0,0 +1,58 @@
package de.ladkau.fuelish.ui.receipt
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import de.ladkau.fuelish.data.ExtractedReceiptDto
import de.ladkau.fuelish.data.Receipt
import de.ladkau.fuelish.data.ReceiptRepository
import de.ladkau.fuelish.log.AppLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/** Backs [ReceiptDetailScreen] - loads one already-saved [Receipt] by id
* and lets it be edited in place or deleted, reusing [ReceiptReviewScreen]
* (the same UI ui/capture/CaptureScreen.kt uses right after a scan). */
class ReceiptDetailViewModel(application: Application, private val receiptId: Long) : AndroidViewModel(application) {
private val repository = ReceiptRepository(application)
private val _receipt = MutableStateFlow<Receipt?>(null)
val receipt: StateFlow<Receipt?> = _receipt.asStateFlow()
private val _deleted = MutableStateFlow(false)
val deleted: StateFlow<Boolean> = _deleted.asStateFlow()
init {
viewModelScope.launch {
_receipt.value = repository.getById(receiptId)
}
}
fun update(edited: ExtractedReceiptDto) {
val current = _receipt.value ?: return
viewModelScope.launch {
val updated = current.copy(
date = edited.date,
time = edited.time,
pricePerLitre = edited.pricePerLitre,
litres = edited.litres,
discount = edited.discount,
totalPrice = edited.totalPrice,
)
repository.update(updated)
AppLog.log("Updated receipt id=$receiptId")
_receipt.value = updated
}
}
fun delete() {
val current = _receipt.value ?: return
viewModelScope.launch {
repository.delete(current)
AppLog.log("Deleted receipt id=$receiptId")
_deleted.value = true
}
}
}
@@ -0,0 +1,171 @@
package de.ladkau.fuelish.ui.receipt
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.ladkau.fuelish.R
import de.ladkau.fuelish.data.ExtractedReceiptDto
import de.ladkau.fuelish.data.Plausibility
import de.ladkau.fuelish.data.checkPlausibility
private val PlausibleGreen = Color(0xFF2E7D32)
private val DubiousRed = Color(0xFFC62828)
/**
* Shared "look at one receipt" screen: an editable Details tab (with a
* live plausibility check) and a Photo tab with the source image, used
* both right after a scan (ui/capture/CaptureScreen.kt) and when drilling
* into an already-saved receipt from the overview list
* (ui/receipt/ReceiptDetailScreen.kt) - same UI either way, just a
* different [saveLabel] and whether [onDelete] is provided.
*/
@Composable
fun ReceiptReviewScreen(
initial: ExtractedReceiptDto,
imageUri: Uri?,
onSave: (ExtractedReceiptDto) -> Unit,
saveLabel: String,
onDelete: (() -> Unit)? = null,
) {
var date by remember(initial) { mutableStateOf(initial.date.orEmpty()) }
var time by remember(initial) { mutableStateOf(initial.time.orEmpty()) }
var pricePerLitre by remember(initial) { mutableStateOf(initial.pricePerLitre?.toString().orEmpty()) }
var litres by remember(initial) { mutableStateOf(initial.litres?.toString().orEmpty()) }
var discount by remember(initial) { mutableStateOf(initial.discount?.toString().orEmpty()) }
var totalPrice by remember(initial) { mutableStateOf(initial.totalPrice?.toString().orEmpty()) }
val plausibility = remember(pricePerLitre, litres, discount, totalPrice) {
checkPlausibility(pricePerLitre.toDoubleOrNull(), litres.toDoubleOrNull(), discount.toDoubleOrNull(), totalPrice.toDoubleOrNull())
}
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
Column(modifier = Modifier.fillMaxSize()) {
TabRow(selectedTabIndex = selectedTab) {
Tab(
selected = selectedTab == 0,
onClick = { selectedTab = 0 },
text = { Text(stringResource(R.string.receipt_tab_details)) },
)
Tab(
selected = selectedTab == 1,
onClick = { selectedTab = 1 },
text = { Text(stringResource(R.string.receipt_tab_photo)) },
)
}
when (selectedTab) {
0 -> Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
PlausibilityBadge(plausibility)
LabeledField(R.string.capture_field_date, date) { date = it }
LabeledField(R.string.capture_field_time, time) { time = it }
LabeledField(R.string.capture_field_price_per_litre, pricePerLitre) { pricePerLitre = it }
LabeledField(R.string.capture_field_litres, litres) { litres = it }
LabeledField(R.string.capture_field_discount, discount) { discount = it }
LabeledField(R.string.capture_field_total_price, totalPrice) { totalPrice = it }
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxWidth()
.align(Alignment.End),
) {
if (onDelete != null) {
OutlinedButton(onClick = onDelete) {
Text(stringResource(R.string.receipt_delete))
}
}
Button(
onClick = {
onSave(
ExtractedReceiptDto(
date = date.ifBlank { null },
time = time.ifBlank { null },
pricePerLitre = pricePerLitre.toDoubleOrNull(),
litres = litres.toDoubleOrNull(),
discount = discount.toDoubleOrNull(),
totalPrice = totalPrice.toDoubleOrNull(),
),
)
},
) {
Text(saveLabel)
}
}
}
else -> Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
if (imageUri != null) {
ZoomableImage(uri = imageUri, modifier = Modifier.fillMaxSize())
} else {
Text(stringResource(R.string.receipt_no_photo))
}
}
}
}
}
@Composable
private fun PlausibilityBadge(plausibility: Plausibility) {
val (color, textRes) = when (plausibility) {
Plausibility.OK -> PlausibleGreen to R.string.receipt_scan_ok
Plausibility.DUBIOUS -> DubiousRed to R.string.receipt_scan_dubious
Plausibility.UNKNOWN -> return
}
Surface(
color = color.copy(alpha = 0.15f),
contentColor = color,
shape = MaterialTheme.shapes.small,
) {
Text(
text = stringResource(textRes),
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
@Composable
private fun LabeledField(labelRes: Int, value: String, onValueChange: (String) -> Unit) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(stringResource(labelRes)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
@@ -0,0 +1,76 @@
package de.ladkau.fuelish.ui.receipt
import android.net.Uri
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.gestures.transformable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import de.ladkau.fuelish.R
/** Pinch-to-zoom + pan, with a double-tap to reset - the minimum viable
* "look closely at a receipt photo" viewer, no separate library needed
* (Compose's own transformable/pointerInput gestures cover it).
*
* Falls back to a text message rather than a blank area if the file is
* missing - e.g. an older receipt saved back when photos were written to
* the cache dir instead of internal storage, and the OS has since cleared
* it (see res/xml/file_paths.xml's own comment). */
@Composable
fun ZoomableImage(uri: Uri, modifier: Modifier = Modifier) {
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
val transformState = rememberTransformableState { zoomChange, panChange, _ ->
scale = (scale * zoomChange).coerceIn(1f, 6f)
offset += panChange
}
SubcomposeAsyncImage(
model = uri,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = modifier.fillMaxSize(),
loading = { Box(Modifier.fillMaxSize()) },
error = {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.receipt_photo_missing))
}
},
success = {
SubcomposeAsyncImageContent(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures(onDoubleTap = {
scale = 1f
offset = Offset.Zero
})
}
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offset.x,
translationY = offset.y,
)
.transformable(transformState),
)
},
)
}
@@ -2,9 +2,12 @@ package de.ladkau.fuelish.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -20,6 +23,7 @@ import de.ladkau.fuelish.R
fun ConfigScreen(viewModel: ConfigViewModel = viewModel()) {
val config by viewModel.config.collectAsState()
val isDirty by viewModel.isDirty.collectAsState()
val testResult by viewModel.testResult.collectAsState()
Column(
modifier = Modifier
@@ -31,12 +35,28 @@ fun ConfigScreen(viewModel: ConfigViewModel = viewModel()) {
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))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { viewModel.save() }, enabled = isDirty) {
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 androidx.lifecycle.AndroidViewModel
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.ConfigRepository
import de.ladkau.fuelish.data.HealthCheckResult
import de.ladkau.fuelish.log.AppLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -14,8 +18,15 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
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) {
private val configRepository = ConfigRepository(application)
private val api = ApiClient()
// A local editable draft, seeded once from the persisted config below -
// 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 }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
private val _testResult = MutableStateFlow<ConnectionTestResult?>(null)
val testResult: StateFlow<ConnectionTestResult?> = _testResult.asStateFlow()
init {
viewModelScope.launch {
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
}
}
fun update(transform: (AppConfig) -> AppConfig) {
_config.value = transform(_config.value)
_testResult.value = null
}
fun save() {
@@ -48,4 +71,21 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
_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)
}
}
}
}
}
+31 -4
View File
@@ -5,8 +5,11 @@
<string name="nav_capture">Scan receipt</string>
<string name="nav_overview">Receipts</string>
<string name="nav_settings">Settings</string>
<string name="nav_log">App log</string>
<string name="nav_back">Back</string>
<string name="capture_take_photo">Take photo</string>
<string name="capture_choose_gallery">Choose from gallery</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>
@@ -14,8 +17,6 @@
<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>
@@ -23,11 +24,37 @@
<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="receipt_tab_details">Details</string>
<string name="receipt_tab_photo">Photo</string>
<string name="receipt_no_photo">No photo available</string>
<string name="receipt_photo_missing">Photo file is missing</string>
<string name="receipt_scan_ok">Scan OK</string>
<string name="receipt_scan_dubious">Scan dubious</string>
<string name="receipt_update">Update</string>
<string name="receipt_delete">Delete</string>
<string name="overview_empty">No receipts have been scanned yet</string>
<string name="overview_column_date">Date</string>
<string name="overview_column_litres">Litres</string>
<string name="overview_column_total">Total</string>
<string name="overview_delete">Delete</string>
<string name="overview_export_csv">Export CSV</string>
<string name="overview_export_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>
<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_copy">Copy log</string>
<string name="log_clear">Clear log</string>
<string name="log_empty">Nothing logged yet</string>
</resources>
+10 -6
View File
@@ -1,12 +1,16 @@
<?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. -->
content:// URIs from. -->
<paths>
<cache-path name="captures" path="captures/" />
<!-- Receipt photos (camera captures and gallery picks) - written to
internal storage, not the cache dir: Receipt.imageUri (see
data/Receipt.kt) is a durable DB reference to this file, and the
cache dir can be cleared by the OS at any time independent of the
database, which silently orphaned it (see ui/capture/CaptureScreen.kt's
own comment). -->
<files-path name="receipts" path="receipts/" />
<!-- Exported CSV/ODS files (see export/CsvExporter.kt, OdsExporter.kt),
shared out via an ACTION_SEND intent - see
ui/overview/OverviewViewModel.kt. -->
shared out via an ACTION_SEND intent - regenerated on every
export, so cache is fine here. -->
<cache-path name="exports" path="exports/" />
</paths>
+2
View File
@@ -26,6 +26,7 @@ kotlinxCoroutines = "1.11.0"
room = "2.8.5"
splashscreen = "1.0.1"
okhttp = "4.12.0"
coil = "3.0.4"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -48,6 +49,7 @@ androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscree
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
@@ -0,0 +1,82 @@
# 3-variant OCR cross-referencing experiment
Tested whether running Tesseract over three image variants of the same
receipt photo (original color photo, plain grayscale, and the existing
denoised/contrast-enhanced preprocessing) and asking the extraction LLM
to cross-reference all three per field would recover more correct data
than the single-OCR-pass baseline, particularly for blurry/low-quality
photos.
## Result
Mixed. One more receipt matched overall, two fields improved
meaningfully, but the original target field (`litres`) did not improve,
and `total_price` regressed.
| Metric | Baseline (1 OCR pass) | New (3-variant cross-ref) |
|---|---|---|
| Matched to ground truth | 15/17 | 16/17 |
| price_per_litre | 11/15 (73%) | 13/16 (81%) ↑ |
| litres | 9/15 (60%) | 9/16 (56%) — unchanged, still worst |
| discount | 11/15 (73%) | 15/16 (94%) ↑↑ |
| total_price | 12/15 (80%) | 11/16 (69%) ↓ |
## Root causes identified
1. **The litres/price-per-litre line is the hardest OCR target**, regardless
of variant count. Noise characters (`x2p 02`, `*/p`) precede the real
numbers and the litre symbol `l` reads as `1`/`]`/`|` in all three
variants alike, so this field stays wrong at the same rate.
2. **Three OCR readings don't guarantee the model picks the right one.**
On the blurry Jet receipt, the correct total `57,13` survives intact in
the original-photo variant, but the model still output `37.13`. Extra
(noisy) input sometimes confuses the small 3B model rather than helping
it reconcile.
3. **`total_price` regressed** — cross-referencing conflicting reads
apparently made the model second-guess fields it used to get right from
a single clean reading.
4. **Severe performance/stability cost** on this 2-core, GPU-less VM:
extraction latency became wildly inconsistent (1–19 minutes per receipt
vs. seconds before), and one run hit a hard timeout before the request
timeout was raised from 300s → 900s. The full 17-receipt batch that used
to finish in ~11 minutes now takes well over an hour.
## Working examples (fully correct — all 4 fields + date matched)
- `IMG_20260726_095120207`, `095321692`, `095428941`, `095442114`,
`095510481`, `095519964` — all Shell receipts with clean, high-contrast
OCR text across all three variants.
## Not-working examples
- **`IMG_20260525_182252232`** (the blurry Jet receipt that prompted this
experiment) — still no date match at all (extracted `13.05.2026` vs. true
`15.05.2026`). All three OCR variants scramble the header; even though
the correct total `57,13` is legible in the original-photo variant, the
model output `37.13` instead.
- **`IMG_20260525_182144541`** — worst case, everything wrong except
date/time/transaction number: extracted `litres=1.205` (should be ~23),
`price_per_litre=1.389` (plausible alone, but paired with nonsense
litres), `discount=-0.08` (sign flipped), `total_price=5.33`. All three
OCR variants are near-unreadable for this receipt.
- **`IMG_20260525_182116766`** — `litres` read correctly (`20.24`, matches
the OCR text `"20,24 1 1979 EUR/]"` cleanly) but `price_per_litre` came
out as `6.01` instead of `~1.979` — a pure extraction/reconciliation
error, not an OCR gap, since the right number is sitting right there in
the text.
- **`IMG_20260726_095312079`, `095335197`, `095416655`, `095452747`,
`095501803`** — all wrong only on `litres`, the recurring weak field.
- **`IMG_20260726_095215757`** — wrong on `litres` and `total_price`
together; the OCR text for that line is heavily corrupted
(`j 2 N N 4) ha 1: Hi A 1.869 EUR/`) in all three variants, so this one
genuinely lacks clean signal anywhere.
## Conclusion / recommendation
The extra OCR variants help in a few places (`discount`, `price_per_litre`)
but don't fix the core problem (`litres` line noise), introduce a new
failure mode (the model mis-reconciling conflicting-but-sometimes-correct
reads), and cost a lot of latency/stability on this hardware. Leaning
toward reverting to single-OCR unless a confidence-triggered fallback
(only pull extra variants when the primary reading looks incomplete) is
tried instead.