2 Commits

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

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