From ad2ef1689eb893d184c5df49dae97963c3008d92 Mon Sep 17 00:00:00 2001 From: ml Date: Tue, 29 Sep 2026 19:33:39 +0200 Subject: [PATCH] Improve app UX (server setup, status, gallery, log) and allow 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 --- .gitea/workflows/build.yml | 8 ++- README.md | 13 ++++ .../java/de/ladkau/fuelish/data/ApiClient.kt | 59 +++++++++++++--- .../main/java/de/ladkau/fuelish/log/AppLog.kt | 30 ++++++++ .../ladkau/fuelish/ui/ConnectionStatusBar.kt | 57 +++++++++++++++ .../fuelish/ui/ConnectionStatusViewModel.kt | 69 +++++++++++++++++++ .../java/de/ladkau/fuelish/ui/FuelishApp.kt | 31 ++++++--- .../fuelish/ui/capture/CaptureScreen.kt | 50 +++++++++++--- .../fuelish/ui/capture/CaptureViewModel.kt | 9 +++ .../de/ladkau/fuelish/ui/log/LogScreen.kt | 63 +++++++++++++++++ .../fuelish/ui/overview/OverviewScreen.kt | 48 ++++++++++--- .../fuelish/ui/overview/OverviewViewModel.kt | 7 +- .../fuelish/ui/settings/ConfigScreen.kt | 26 ++++++- .../fuelish/ui/settings/ConfigViewModel.kt | 42 ++++++++++- android/app/src/main/res/values/strings.xml | 22 +++++- 15 files changed, 488 insertions(+), 46 deletions(-) create mode 100644 android/app/src/main/java/de/ladkau/fuelish/log/AppLog.kt create mode 100644 android/app/src/main/java/de/ladkau/fuelish/ui/ConnectionStatusBar.kt create mode 100644 android/app/src/main/java/de/ladkau/fuelish/ui/ConnectionStatusViewModel.kt create mode 100644 android/app/src/main/java/de/ladkau/fuelish/ui/log/LogScreen.kt diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 5fd6cab..82473d4 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -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)" diff --git a/README.md b/README.md index 2eb5441..de5d8ba 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/android/app/src/main/java/de/ladkau/fuelish/data/ApiClient.kt b/android/app/src/main/java/de/ladkau/fuelish/data/ApiClient.kt index bfa388e..e149d96 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/data/ApiClient.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/data/ApiClient.kt @@ -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 } diff --git a/android/app/src/main/java/de/ladkau/fuelish/log/AppLog.kt b/android/app/src/main/java/de/ladkau/fuelish/log/AppLog.kt new file mode 100644 index 0000000..030c9e2 --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/log/AppLog.kt @@ -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>(emptyList()) + val entries: StateFlow> = _entries.asStateFlow() + + fun log(message: String) { + _entries.update { (it + Entry(System.currentTimeMillis(), message)).takeLast(MAX_ENTRIES) } + } + + fun clear() { + _entries.value = emptyList() + } +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/ConnectionStatusBar.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/ConnectionStatusBar.kt new file mode 100644 index 0000000..457217e --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/ConnectionStatusBar.kt @@ -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) + } +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/ConnectionStatusViewModel.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/ConnectionStatusViewModel.kt new file mode 100644 index 0000000..a6d90c7 --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/ConnectionStatusViewModel.kt @@ -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 = _status.asStateFlow() + + private val _serverUrl = MutableStateFlow("") + val serverUrl: StateFlow = _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) + } + } +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/FuelishApp.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/FuelishApp.kt index a0e6080..030da30 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/ui/FuelishApp.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/FuelishApp.kt @@ -26,6 +26,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import de.ladkau.fuelish.R import de.ladkau.fuelish.ui.capture.CaptureScreen +import de.ladkau.fuelish.ui.log.LogScreen import de.ladkau.fuelish.ui.overview.OverviewScreen import de.ladkau.fuelish.ui.settings.ConfigScreen import kotlinx.coroutines.launch @@ -34,10 +35,12 @@ private object Destinations { const val CAPTURE = "capture" const val OVERVIEW = "overview" const val SETTINGS = "settings" + const val LOG = "log" } -/** Burger menu = ModalNavigationDrawer, leading to Scan/Receipts/Settings - - * the app's single navigation host. */ +/** Burger menu = ModalNavigationDrawer, leading to Receipts/Scan/Settings/App + * 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) @Composable fun FuelishApp() { @@ -50,24 +53,30 @@ fun FuelishApp() { drawerState = drawerState, drawerContent = { ModalDrawerSheet { - NavigationDrawerItem( - label = { Text(stringResource(R.string.nav_capture)) }, - selected = currentRoute == Destinations.CAPTURE, - onClick = { navigateTo(navController, Destinations.CAPTURE); scope.launch { drawerState.close() } }, - modifier = Modifier.padding(horizontal = 12.dp), - ) NavigationDrawerItem( label = { Text(stringResource(R.string.nav_overview)) }, selected = currentRoute == Destinations.OVERVIEW, onClick = { navigateTo(navController, Destinations.OVERVIEW); scope.launch { drawerState.close() } }, modifier = Modifier.padding(horizontal = 12.dp), ) + NavigationDrawerItem( + label = { Text(stringResource(R.string.nav_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_settings)) }, selected = currentRoute == Destinations.SETTINGS, 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), + ) } }, ) { @@ -82,15 +91,17 @@ fun FuelishApp() { }, ) }, + 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.CAPTURE) { CaptureScreen() } composable(Destinations.SETTINGS) { ConfigScreen() } + composable(Destinations.LOG) { LogScreen() } } } } diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureScreen.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureScreen.kt index 75f36df..0f5b33b 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureScreen.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureScreen.kt @@ -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 @@ -65,16 +67,32 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) { 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)) { when (val state = uiState) { - is CaptureUiState.Idle -> Button( - onClick = { - val hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == - PackageManager.PERMISSION_GRANTED - if (hasPermission) launchCamera() else cameraPermissionLauncher.launch(Manifest.permission.CAMERA) - }, - ) { - Text(stringResource(R.string.capture_take_photo)) + is CaptureUiState.Idle -> 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)) + } } 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 private fun ProgressStage(label: String, progress: Float) { Column( diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureViewModel.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureViewModel.kt index 0d32a9f..5097efa 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureViewModel.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureViewModel.kt @@ -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 -> { + AppLog.log("Job $jobId done") _uiState.value = CaptureUiState.Editing(status.result ?: ExtractedReceiptDto()) 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) } @@ -84,6 +92,7 @@ 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 } diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/log/LogScreen.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/log/LogScreen.kt new file mode 100644 index 0000000..365dff6 --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/log/LogScreen.kt @@ -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}" diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/overview/OverviewScreen.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/overview/OverviewScreen.kt index b54ee98..3ef4f26 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/ui/overview/OverviewScreen.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/overview/OverviewScreen.kt @@ -7,6 +7,7 @@ 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 @@ -15,6 +16,7 @@ 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 @@ -28,6 +30,8 @@ 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()) { val receipts by viewModel.receipts.collectAsState() @@ -48,6 +52,9 @@ fun OverviewScreen(viewModel: OverviewViewModel = viewModel()) { } } + ReceiptTableHeader() + HorizontalDivider() + if (receipts.isEmpty()) { Text( text = stringResource(R.string.overview_empty), @@ -65,20 +72,45 @@ 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_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)) } } } diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/overview/OverviewViewModel.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/overview/OverviewViewModel.kt index aacfb32..642dfb7 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/ui/overview/OverviewViewModel.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/overview/OverviewViewModel.kt @@ -9,6 +9,7 @@ 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted @@ -24,7 +25,10 @@ class OverviewViewModel(application: Application) : AndroidViewModel(application .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) 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) @@ -40,6 +44,7 @@ class OverviewViewModel(application: Application) : AndroidViewModel(application file.outputStream().use { write(receipts.value, it) } FileProvider.getUriForFile(app, "${app.packageName}.fileprovider", file) } + AppLog.log("Exported ${receipts.value.size} receipt(s) to $fileName") onReady(uri) } } diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/settings/ConfigScreen.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/settings/ConfigScreen.kt index 18a6439..bca6f6b 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/ui/settings/ConfigScreen.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/settings/ConfigScreen.kt @@ -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 } } } diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/settings/ConfigViewModel.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/settings/ConfigViewModel.kt index 63ae510..1844c6a 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/ui/settings/ConfigViewModel.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/settings/ConfigViewModel.kt @@ -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 = combine(_config, _savedConfig) { draft, saved -> draft != saved } .stateIn(viewModelScope, SharingStarted.Eagerly, false) + private val _testResult = MutableStateFlow(null) + val testResult: StateFlow = _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().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) + } + } + } + } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 882625c..27b519d 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -5,8 +5,10 @@ Scan receipt Receipts Settings + App log Take photo + Choose from gallery Uploading… Preprocessing image… Reading text (OCR)… @@ -23,11 +25,27 @@ Discount Total price - No receipts saved yet + No receipts have been scanned yet + Date + Station + Total + Delete Export CSV Export ODS Exported to %1$s Server URL - http://192.168.1.10:8080 + http://192.168.1.10:8080 + Test connection + Testing… + Connected successfully + Could not reach server: %1$s + + Connected to %1$s + Cannot reach %1$s + No server configured - see Settings + Checking connection… + + Clear log + Nothing logged yet