Compare commits
1 Commits
68e0cd8967
..
v0.5.2
| Author | SHA1 | Date | |
|---|---|---|---|
| ad2ef1689e |
@@ -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)"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
|
||||||
Reference in New Issue
Block a user