1 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
16 changed files with 488 additions and 128 deletions
+5 -3
View File
@@ -57,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)"
+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 and publishes the APK to dl.ladkau.de on every push to the default
branch. 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 ## Running
**Inspect the pipeline stage by stage**, for development/debugging one **Inspect the pipeline stage by stage**, for development/debugging one
@@ -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,16 +25,28 @@ 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) {
val body = MultipartBody.Builder() try {
.setType(MultipartBody.FORM) val body = MultipartBody.Builder()
.addFormDataPart("image", imageFile.name, imageFile.asRequestBody("image/jpeg".toMediaType())) .setType(MultipartBody.FORM)
.build() .addFormDataPart("image", imageFile.name, imageFile.asRequestBody("image/jpeg".toMediaType()))
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs").post(body).build() .build()
client.newCall(request).execute().use { response -> val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs").post(body).build()
if (!response.isSuccessful) throw IOException("upload failed: HTTP ${response.code}") client.newCall(request).execute().use { response ->
val text = response.body?.string().orEmpty() if (!response.isSuccessful) throw IOException("upload failed: HTTP ${response.code}")
json.decodeFromString(CreateJobResponse.serializer(), text).jobId 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) 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,16 +67,32 @@ 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)) {
onClick = { Button(
val hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == onClick = {
PackageManager.PERMISSION_GRANTED val hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
if (hasPermission) launchCamera() else cameraPermissionLauncher.launch(Manifest.permission.CAMERA) PackageManager.PERMISSION_GRANTED
}, if (hasPermission) launchCamera() else cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
) { },
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),
IconButton(onClick = onDelete) { )
Icon(Icons.Filled.Delete, contentDescription = null) 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)) {}
}
}
@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(),
) )
Button(onClick = { viewModel.save() }, enabled = isDirty) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.capture_save)) 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 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>
@@ -1,82 +0,0 @@
# 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.