diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9450e28..6de8c50 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -75,5 +75,6 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.coroutines.android) implementation(libs.okhttp) + implementation(libs.coil.compose) debugImplementation(libs.androidx.ui.tooling) } diff --git a/android/app/src/main/java/de/ladkau/fuelish/data/FuelishDatabase.kt b/android/app/src/main/java/de/ladkau/fuelish/data/FuelishDatabase.kt index 6281853..d3f814b 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/data/FuelishDatabase.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/data/FuelishDatabase.kt @@ -5,7 +5,12 @@ import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase -@Database(entities = [Receipt::class], version = 1, exportSchema = false) +// version 2: dropped Receipt.petrolStation/transactionNumber. No migration +// path - fallbackToDestructiveMigration() wipes any locally-saved receipts +// on upgrade. Acceptable during active development (personal, single-user +// app, no real deployment yet); revisit with a real migration before that +// changes. +@Database(entities = [Receipt::class], version = 2, exportSchema = false) abstract class FuelishDatabase : RoomDatabase() { abstract fun receiptDao(): ReceiptDao @@ -19,7 +24,10 @@ abstract class FuelishDatabase : RoomDatabase() { context.applicationContext, FuelishDatabase::class.java, "fuelish.db", - ).build().also { instance = it } + ) + .fallbackToDestructiveMigration(dropAllTables = true) + .build() + .also { instance = it } } } } diff --git a/android/app/src/main/java/de/ladkau/fuelish/data/JobDto.kt b/android/app/src/main/java/de/ladkau/fuelish/data/JobDto.kt index 185dc8d..fd6fda8 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/data/JobDto.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/data/JobDto.kt @@ -23,11 +23,12 @@ data class JobStatusResponse( } } -/** Mirrors tooling-container/src/extract.py's SCHEMA. */ +/** Mirrors the fields of tooling-container/src/extract.py's SCHEMA this + * app actually keeps - petrol_station/transaction_number come back too + * but are ignored (ignoreUnknownKeys=true in ApiClient's Json), since + * they're not useful for a personal fuel-cost log. */ @Serializable data class ExtractedReceiptDto( - val petrolStation: String? = null, - val transactionNumber: String? = null, val date: String? = null, val time: String? = null, val pricePerLitre: Double? = null, diff --git a/android/app/src/main/java/de/ladkau/fuelish/data/Receipt.kt b/android/app/src/main/java/de/ladkau/fuelish/data/Receipt.kt index 1c185ac..135d227 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/data/Receipt.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/data/Receipt.kt @@ -7,12 +7,14 @@ import androidx.room.PrimaryKey * A confirmed receipt, as the user saved it after reviewing the server's * extraction. This app is the system of record - the server is * stateless processing only (see docker-compose.yml / server.py). + * + * Deliberately doesn't keep petrol_station/transaction_number even though + * the server extracts them (see ExtractedReceiptDto) - not useful for a + * personal fuel-cost log, so there's no reason to store or show them. */ @Entity(tableName = "receipts") data class Receipt( @PrimaryKey(autoGenerate = true) val id: Long = 0, - val petrolStation: String?, - val transactionNumber: String?, /** DD.MM.YYYY, matching testdata/.../benzin_kosten.ods's ground-truth format. */ val date: String?, /** HH:MM:SS. */ diff --git a/android/app/src/main/java/de/ladkau/fuelish/data/ReceiptPlausibility.kt b/android/app/src/main/java/de/ladkau/fuelish/data/ReceiptPlausibility.kt new file mode 100644 index 0000000..c45bb3e --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/data/ReceiptPlausibility.kt @@ -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 +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/data/ReceiptRepository.kt b/android/app/src/main/java/de/ladkau/fuelish/data/ReceiptRepository.kt index 180e90a..6f15059 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/data/ReceiptRepository.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/data/ReceiptRepository.kt @@ -13,6 +13,8 @@ class ReceiptRepository(context: Context) { val receipts: Flow> = dao.observeAll() + suspend fun getById(id: Long): Receipt? = dao.getById(id) + suspend fun submitImage(baseUrl: String, imageFile: File): String = api.submitImage(baseUrl, imageFile) suspend fun pollJob(baseUrl: String, jobId: String): JobStatusResponse = api.getJobStatus(baseUrl, jobId) diff --git a/android/app/src/main/java/de/ladkau/fuelish/export/ReceiptRow.kt b/android/app/src/main/java/de/ladkau/fuelish/export/ReceiptRow.kt index 5e7c37c..f815af8 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/export/ReceiptRow.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/export/ReceiptRow.kt @@ -4,18 +4,13 @@ import de.ladkau.fuelish.data.Receipt import java.util.Locale /** - * The first 6 columns mirror testdata/.../benzin_kosten.ods's layout - * exactly (date, time, price_per_litre, litres, discount, total_price - - * see tooling-container/src/compare_ground_truth.py's column order and - * its German-locale decimal-comma / "€"-suffix parsing, which this - * reproduces on the way out). petrol_station/transaction_number are - * appended as extra trailing columns - present in this app's data but - * not in the original ground-truth sheet. + * Mirrors testdata/.../benzin_kosten.ods's layout exactly (date, time, + * price_per_litre, litres, discount, total_price - see + * tooling-container/src/compare_ground_truth.py's column order and its + * German-locale decimal-comma / "€"-suffix parsing, which this + * reproduces on the way out). */ -val EXPORT_HEADERS = listOf( - "Datum", "Uhrzeit", "Preis/Liter", "Liter", "Rabatt", "Gesamtpreis", - "Tankstelle", "Transaktionsnummer", -) +val EXPORT_HEADERS = listOf("Datum", "Uhrzeit", "Preis/Liter", "Liter", "Rabatt", "Gesamtpreis") fun Receipt.toExportRow(): List = listOf( date.orEmpty(), @@ -24,6 +19,4 @@ fun Receipt.toExportRow(): List = listOf( litres?.let { String.format(Locale.GERMANY, "%.2f", it) }.orEmpty(), discount?.let { String.format(Locale.GERMANY, "%.2f €", it) }.orEmpty(), totalPrice?.let { String.format(Locale.GERMANY, "%.2f €", it) }.orEmpty(), - petrolStation.orEmpty(), - transactionNumber.orEmpty(), ) diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/ConnectionStatusBar.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/ConnectionStatusBar.kt index 457217e..f2bb0fe 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/ui/ConnectionStatusBar.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/ConnectionStatusBar.kt @@ -4,9 +4,12 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -42,6 +45,11 @@ fun ConnectionStatusBar(viewModel: ConnectionStatusViewModel = viewModel()) { modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surfaceVariant) + // Edge-to-edge (MainActivity.enableEdgeToEdge()) means this bar + // would otherwise draw under the system gesture nav bar at the + // bottom of the screen, overlapping its swipe area - push the + // content above it while the background still extends full-bleed. + .windowInsetsPadding(WindowInsets.navigationBars) .padding(horizontal = 16.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/ExportViewModel.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/ExportViewModel.kt new file mode 100644 index 0000000..9a739fc --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/ExportViewModel.kt @@ -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, OutputStream) -> Unit, onReady: (Uri) -> Unit) { + val app = getApplication() + 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) + } + } +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/FuelishApp.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/FuelishApp.kt index 030da30..bcd5018 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/ui/FuelishApp.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/FuelishApp.kt @@ -1,10 +1,15 @@ package de.ladkau.fuelish.ui +import android.content.Intent import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Menu import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ModalDrawerSheet @@ -17,37 +22,59 @@ import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument import de.ladkau.fuelish.R import de.ladkau.fuelish.ui.capture.CaptureScreen import de.ladkau.fuelish.ui.log.LogScreen import de.ladkau.fuelish.ui.overview.OverviewScreen +import de.ladkau.fuelish.ui.receipt.ReceiptDetailScreen import de.ladkau.fuelish.ui.settings.ConfigScreen import kotlinx.coroutines.launch private object Destinations { - const val CAPTURE = "capture" const val OVERVIEW = "overview" + const val CAPTURE = "capture" const val SETTINGS = "settings" const val LOG = "log" + const val RECEIPT_DETAIL_ARG = "receiptId" + const val RECEIPT_DETAIL = "receipt_detail/{$RECEIPT_DETAIL_ARG}" + + fun receiptDetail(id: Long) = "receipt_detail/$id" + + // Reached via the drawer, so the top bar shows the hamburger menu. + // Anything else (Capture, receipt detail) is reached by pushing on top + // of one of these, so it gets a back arrow instead - see FuelishApp's + // navigationIcon below. + val DRAWER_DESTINATIONS = setOf(OVERVIEW, SETTINGS, LOG) } -/** Burger menu = ModalNavigationDrawer, leading to Receipts/Scan/Settings/App - * log - the app's single navigation host. Receipts is the front page (start - * destination) since that's what most opens of the app want to see. */ +/** + * Burger menu = ModalNavigationDrawer, leading to Receipts/Settings/App + * log plus the two export actions - Receipts is the front page (start + * destination) since that's what most opens of the app want to see, and + * scanning a new receipt is reached from its own "+" FAB rather than a + * separate always-visible tab (a bare scan screen with nothing else on it + * was pretty empty on its own). + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun FuelishApp() { val navController = rememberNavController() val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() + val context = LocalContext.current val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route + val exportViewModel: ExportViewModel = viewModel() ModalNavigationDrawer( drawerState = drawerState, @@ -59,12 +86,6 @@ fun FuelishApp() { onClick = { navigateTo(navController, Destinations.OVERVIEW); scope.launch { drawerState.close() } }, modifier = Modifier.padding(horizontal = 12.dp), ) - NavigationDrawerItem( - label = { Text(stringResource(R.string.nav_capture)) }, - selected = currentRoute == Destinations.CAPTURE, - onClick = { navigateTo(navController, Destinations.CAPTURE); scope.launch { drawerState.close() } }, - modifier = Modifier.padding(horizontal = 12.dp), - ) NavigationDrawerItem( label = { Text(stringResource(R.string.nav_settings)) }, selected = currentRoute == Destinations.SETTINGS, @@ -77,6 +98,25 @@ fun FuelishApp() { onClick = { navigateTo(navController, Destinations.LOG); scope.launch { drawerState.close() } }, modifier = Modifier.padding(horizontal = 12.dp), ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + NavigationDrawerItem( + label = { Text(stringResource(R.string.overview_export_csv)) }, + selected = false, + onClick = { + scope.launch { drawerState.close() } + exportViewModel.exportCsv { uri -> shareFile(context, uri, "text/csv") } + }, + modifier = Modifier.padding(horizontal = 12.dp), + ) + NavigationDrawerItem( + label = { Text(stringResource(R.string.overview_export_ods)) }, + selected = false, + onClick = { + scope.launch { drawerState.close() } + exportViewModel.exportOds { uri -> shareFile(context, uri, "application/vnd.oasis.opendocument.spreadsheet") } + }, + modifier = Modifier.padding(horizontal = 12.dp), + ) } }, ) { @@ -85,12 +125,25 @@ fun FuelishApp() { TopAppBar( title = { Text(stringResource(R.string.app_name)) }, navigationIcon = { - IconButton(onClick = { scope.launch { drawerState.open() } }) { - Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.nav_settings)) + if (currentRoute in Destinations.DRAWER_DESTINATIONS) { + IconButton(onClick = { scope.launch { drawerState.open() } }) { + Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.nav_settings)) + } + } else { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.nav_back)) + } } }, ) }, + floatingActionButton = { + if (currentRoute == Destinations.OVERVIEW) { + FloatingActionButton(onClick = { navController.navigate(Destinations.CAPTURE) }) { + Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.nav_capture)) + } + } + }, bottomBar = { ConnectionStatusBar() }, ) { innerPadding -> NavHost( @@ -98,10 +151,21 @@ fun FuelishApp() { startDestination = Destinations.OVERVIEW, modifier = Modifier.padding(innerPadding), ) { - composable(Destinations.OVERVIEW) { OverviewScreen() } - composable(Destinations.CAPTURE) { CaptureScreen() } + composable(Destinations.OVERVIEW) { + OverviewScreen(onReceiptClick = { id -> navController.navigate(Destinations.receiptDetail(id)) }) + } + composable(Destinations.CAPTURE) { + CaptureScreen(onSaved = { navController.popBackStack() }) + } composable(Destinations.SETTINGS) { ConfigScreen() } composable(Destinations.LOG) { LogScreen() } + composable( + route = Destinations.RECEIPT_DETAIL, + arguments = listOf(navArgument(Destinations.RECEIPT_DETAIL_ARG) { type = NavType.LongType }), + ) { backStackEntry -> + val id = backStackEntry.arguments?.getLong(Destinations.RECEIPT_DETAIL_ARG) ?: return@composable + ReceiptDetailScreen(receiptId = id, onDeleted = { navController.popBackStack() }) + } } } } @@ -114,3 +178,12 @@ private fun navigateTo(navController: NavHostController, route: String) { restoreState = true } } + +private fun shareFile(context: android.content.Context, uri: android.net.Uri, mimeType: String) { + val intent = Intent(Intent.ACTION_SEND).apply { + type = mimeType + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, null)) +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureScreen.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureScreen.kt index 0f5b33b..30944ad 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureScreen.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureScreen.kt @@ -13,11 +13,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -25,7 +22,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -34,12 +30,12 @@ import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.lifecycle.viewmodel.compose.viewModel import de.ladkau.fuelish.R -import de.ladkau.fuelish.data.ExtractedReceiptDto import de.ladkau.fuelish.data.JobStatusResponse +import de.ladkau.fuelish.ui.receipt.ReceiptReviewScreen import java.io.File @Composable -fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) { +fun CaptureScreen(onSaved: () -> Unit, viewModel: CaptureViewModel = viewModel()) { val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current @@ -55,7 +51,7 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) { } fun launchCamera() { - val dir = File(context.cacheDir, "captures").apply { mkdirs() } + val dir = File(context.filesDir, "receipts").apply { mkdirs() } val file = File(dir, "capture-${System.currentTimeMillis()}.jpg") val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) pendingFile = file @@ -67,16 +63,25 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) { if (granted) launchCamera() } - val pickImageLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> - if (uri != null) { - val file = copyPickedImageToCache(context, uri) - if (file != null) viewModel.submit(file, uri) + val pickImageLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { pickedUri -> + if (pickedUri != null) { + val file = copyPickedImageToCache(context, pickedUri) + if (file != null) { + // Must be a Uri onto our own persisted copy, NOT pickedUri + // itself - the system Photo Picker's content://media/picker/... + // Uri is a short-lived grant that stops resolving once this + // session ends (e.g. after the app is closed and reopened), + // regardless of whether the underlying bytes were copied + // somewhere durable. + val persistedUri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + viewModel.submit(file, persistedUri) + } } } - Box(modifier = Modifier.fillMaxSize().padding(16.dp)) { - when (val state = uiState) { - is CaptureUiState.Idle -> Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + when (val state = uiState) { + is CaptureUiState.Idle -> Box(modifier = Modifier.fillMaxSize().padding(16.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Button( onClick = { val hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == @@ -94,22 +99,31 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) { 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 -> { - val (labelRes, progress) = when (state.stage) { - JobStatusResponse.STATUS_PREPROCESSING -> R.string.capture_stage_preprocessing to 0.45f - JobStatusResponse.STATUS_OCR -> R.string.capture_stage_ocr to 0.7f - JobStatusResponse.STATUS_EXTRACTING -> R.string.capture_stage_extracting to 0.9f - else -> R.string.capture_stage_uploading to 0.2f - } - ProgressStage(stringResource(labelRes), progress) + is CaptureUiState.Processing -> Box(modifier = Modifier.fillMaxSize().padding(16.dp)) { + val (labelRes, progress) = when (state.stage) { + JobStatusResponse.STATUS_PREPROCESSING -> R.string.capture_stage_preprocessing to 0.45f + JobStatusResponse.STATUS_OCR -> R.string.capture_stage_ocr to 0.7f + JobStatusResponse.STATUS_EXTRACTING -> R.string.capture_stage_extracting to 0.9f + else -> R.string.capture_stage_uploading to 0.2f } + ProgressStage(stringResource(labelRes), progress) + } - is CaptureUiState.Editing -> 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)) 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() * 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? { - val dir = File(context.cacheDir, "captures").apply { mkdirs() } + val dir = File(context.filesDir, "receipts").apply { mkdirs() } val file = File(dir, "picked-${System.currentTimeMillis()}.jpg") return try { context.contentResolver.openInputStream(uri)?.use { input -> @@ -143,62 +158,3 @@ private fun ProgressStage(label: String, progress: Float) { LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth()) } } - -@Composable -private fun EditForm(draft: ExtractedReceiptDto, onSave: (ExtractedReceiptDto) -> Unit) { - var petrolStation by remember { mutableStateOf(draft.petrolStation.orEmpty()) } - var transactionNumber by remember { mutableStateOf(draft.transactionNumber.orEmpty()) } - var date by remember { mutableStateOf(draft.date.orEmpty()) } - var time by remember { mutableStateOf(draft.time.orEmpty()) } - var pricePerLitre by remember { mutableStateOf(draft.pricePerLitre?.toString().orEmpty()) } - var litres by remember { mutableStateOf(draft.litres?.toString().orEmpty()) } - var discount by remember { mutableStateOf(draft.discount?.toString().orEmpty()) } - var totalPrice by remember { mutableStateOf(draft.totalPrice?.toString().orEmpty()) } - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - LabeledField(R.string.capture_field_petrol_station, petrolStation) { petrolStation = it } - LabeledField(R.string.capture_field_transaction_number, transactionNumber) { transactionNumber = it } - LabeledField(R.string.capture_field_date, date) { date = it } - LabeledField(R.string.capture_field_time, time) { time = it } - LabeledField(R.string.capture_field_price_per_litre, pricePerLitre) { pricePerLitre = it } - LabeledField(R.string.capture_field_litres, litres) { litres = it } - LabeledField(R.string.capture_field_discount, discount) { discount = it } - LabeledField(R.string.capture_field_total_price, totalPrice) { totalPrice = it } - - Button( - onClick = { - onSave( - ExtractedReceiptDto( - petrolStation = petrolStation.ifBlank { null }, - transactionNumber = transactionNumber.ifBlank { null }, - date = date.ifBlank { null }, - time = time.ifBlank { null }, - pricePerLitre = pricePerLitre.toDoubleOrNull(), - litres = litres.toDoubleOrNull(), - discount = discount.toDoubleOrNull(), - totalPrice = totalPrice.toDoubleOrNull(), - ), - ) - }, - modifier = Modifier.align(Alignment.End), - ) { - Text(stringResource(R.string.capture_save)) - } - } -} - -@Composable -private fun LabeledField(labelRes: Int, value: String, onValueChange: (String) -> Unit) { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - label = { Text(stringResource(labelRes)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) -} diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureUiState.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureUiState.kt index 02e42f2..8a4d7bc 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureUiState.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureUiState.kt @@ -1,5 +1,6 @@ package de.ladkau.fuelish.ui.capture +import android.net.Uri import de.ladkau.fuelish.data.ExtractedReceiptDto sealed interface CaptureUiState { @@ -9,6 +10,6 @@ sealed interface CaptureUiState { /** [stage] is one of JobStatusResponse's STATUS_* constants (preprocessing/ocr/extracting). */ data class Processing(val stage: String) : CaptureUiState - data class Editing(val draft: ExtractedReceiptDto) : CaptureUiState + data class Editing(val draft: ExtractedReceiptDto, val imageUri: Uri?) : CaptureUiState data class Error(val message: String) : CaptureUiState } diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureViewModel.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureViewModel.kt index 5097efa..6522adc 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureViewModel.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureViewModel.kt @@ -56,7 +56,7 @@ class CaptureViewModel(application: Application) : AndroidViewModel(application) when (status.status) { JobStatusResponse.STATUS_DONE -> { AppLog.log("Job $jobId done") - _uiState.value = CaptureUiState.Editing(status.result ?: ExtractedReceiptDto()) + _uiState.value = CaptureUiState.Editing(status.result ?: ExtractedReceiptDto(), pendingImageUri) return } 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 { repository.save( Receipt( - petrolStation = draft.petrolStation, - transactionNumber = draft.transactionNumber, date = draft.date, time = draft.time, pricePerLitre = draft.pricePerLitre, @@ -95,6 +93,7 @@ class CaptureViewModel(application: Application) : AndroidViewModel(application) AppLog.log("Saved receipt (date=${draft.date}, total=${draft.totalPrice})") pendingImageUri = null _uiState.value = CaptureUiState.Idle + onSaved() } } diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/log/LogScreen.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/log/LogScreen.kt index 365dff6..0820ca4 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/ui/log/LogScreen.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/log/LogScreen.kt @@ -1,11 +1,14 @@ package de.ladkau.fuelish.ui.log import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Button import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -14,7 +17,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import de.ladkau.fuelish.R @@ -26,13 +31,23 @@ import java.util.Locale @Composable fun LogScreen() { val entries by AppLog.entries.collectAsState() + val clipboard = LocalClipboardManager.current Column(modifier = Modifier.fillMaxSize()) { - Button( - onClick = { AppLog.clear() }, + Row( 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()) { @@ -40,17 +55,21 @@ fun LogScreen() { } 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() + // SelectionContainer: lets text be selected/copied by hand too, + // in addition to the "copy everything" button above. + SelectionContainer { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(entries.asReversed()) { entry -> + Text( + text = formatLogEntry(entry), + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + ) + HorizontalDivider() + } } } } diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/overview/OverviewScreen.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/overview/OverviewScreen.kt index 3ef4f26..7cc8110 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/ui/overview/OverviewScreen.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/overview/OverviewScreen.kt @@ -1,7 +1,6 @@ package de.ladkau.fuelish.ui.overview -import android.content.Intent -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -12,7 +11,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete -import androidx.compose.material3.Button import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -23,7 +21,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -33,25 +30,10 @@ import de.ladkau.fuelish.data.Receipt private val DELETE_COLUMN_WIDTH = 48.dp @Composable -fun OverviewScreen(viewModel: OverviewViewModel = viewModel()) { +fun OverviewScreen(onReceiptClick: (Long) -> Unit, viewModel: OverviewViewModel = viewModel()) { val receipts by viewModel.receipts.collectAsState() - val context = LocalContext.current Column(modifier = Modifier.fillMaxSize()) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Button(onClick = { viewModel.exportCsv { uri -> shareFile(context, uri, "text/csv") } }) { - Text(stringResource(R.string.overview_export_csv)) - } - Button(onClick = { viewModel.exportOds { uri -> shareFile(context, uri, "application/vnd.oasis.opendocument.spreadsheet") } }) { - Text(stringResource(R.string.overview_export_ods)) - } - } - ReceiptTableHeader() HorizontalDivider() @@ -63,7 +45,11 @@ fun OverviewScreen(viewModel: OverviewViewModel = viewModel()) { } else { LazyColumn(modifier = Modifier.fillMaxSize()) { items(receipts, key = { it.id }) { receipt -> - ReceiptRow(receipt = receipt, onDelete = { viewModel.delete(receipt) }) + ReceiptRow( + receipt = receipt, + onClick = { onReceiptClick(receipt.id) }, + onDelete = { viewModel.delete(receipt) }, + ) HorizontalDivider() } } @@ -85,9 +71,9 @@ private fun ReceiptTableHeader() { modifier = Modifier.weight(1f), ) Text( - text = stringResource(R.string.overview_column_station), + text = stringResource(R.string.overview_column_litres), style = MaterialTheme.typography.labelLarge, - modifier = Modifier.weight(2f), + modifier = Modifier.weight(1f), ) Text( text = stringResource(R.string.overview_column_total), @@ -99,27 +85,19 @@ private fun ReceiptTableHeader() { } @Composable -private fun ReceiptRow(receipt: Receipt, onDelete: () -> Unit) { +private fun ReceiptRow(receipt: Receipt, onClick: () -> Unit, onDelete: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() + .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { Text(text = receipt.date.orEmpty(), modifier = Modifier.weight(1f)) - Text(text = receipt.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)) IconButton(onClick = onDelete, modifier = Modifier.width(DELETE_COLUMN_WIDTH)) { 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)) -} diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/overview/OverviewViewModel.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/overview/OverviewViewModel.kt index 642dfb7..6ca8337 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/ui/overview/OverviewViewModel.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/overview/OverviewViewModel.kt @@ -1,22 +1,15 @@ package de.ladkau.fuelish.ui.overview import android.app.Application -import android.net.Uri -import androidx.core.content.FileProvider import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import de.ladkau.fuelish.data.Receipt import de.ladkau.fuelish.data.ReceiptRepository -import de.ladkau.fuelish.export.exportCsv as writeCsvRows -import de.ladkau.fuelish.export.exportOds as writeOdsRows import de.ladkau.fuelish.log.AppLog -import java.io.File -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext class OverviewViewModel(application: Application) : AndroidViewModel(application) { private val repository = ReceiptRepository(application) @@ -30,22 +23,4 @@ class OverviewViewModel(application: Application) : AndroidViewModel(application 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, java.io.OutputStream) -> Unit, onReady: (Uri) -> Unit) { - val app = getApplication() - 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) - } - } } diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/receipt/ReceiptDetailScreen.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/receipt/ReceiptDetailScreen.kt new file mode 100644 index 0000000..85fcd01 --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/receipt/ReceiptDetailScreen.kt @@ -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() }, + ) +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/receipt/ReceiptDetailViewModel.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/receipt/ReceiptDetailViewModel.kt new file mode 100644 index 0000000..c340668 --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/receipt/ReceiptDetailViewModel.kt @@ -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(null) + val receipt: StateFlow = _receipt.asStateFlow() + + private val _deleted = MutableStateFlow(false) + val deleted: StateFlow = _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 + } + } +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/receipt/ReceiptReviewScreen.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/receipt/ReceiptReviewScreen.kt new file mode 100644 index 0000000..dd0cc1b --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/receipt/ReceiptReviewScreen.kt @@ -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(), + ) +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/receipt/ZoomableImage.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/receipt/ZoomableImage.kt new file mode 100644 index 0000000..9feeb85 --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/receipt/ZoomableImage.kt @@ -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), + ) + }, + ) +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 27b519d..e90fb56 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -6,6 +6,7 @@ Receipts Settings App log + Back Take photo Choose from gallery @@ -16,8 +17,6 @@ Something went wrong: %1$s Retry Save - Petrol station - Transaction number Date (DD.MM.YYYY) Time (HH:MM:SS) Price per litre @@ -25,9 +24,18 @@ Discount Total price + Details + Photo + No photo available + Photo file is missing + Scan OK + Scan dubious + Update + Delete + No receipts have been scanned yet Date - Station + Litres Total Delete Export CSV @@ -46,6 +54,7 @@ No server configured - see Settings Checking connection… + Copy log Clear log Nothing logged yet diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml index 8837e47..894d1e4 100644 --- a/android/app/src/main/res/xml/file_paths.xml +++ b/android/app/src/main/res/xml/file_paths.xml @@ -1,12 +1,16 @@ + content:// URIs from. --> - + + + shared out via an ACTION_SEND intent - regenerated on every + export, so cache is fine here. --> diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index fe85691..efe574c 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -26,6 +26,7 @@ kotlinxCoroutines = "1.11.0" room = "2.8.5" splashscreen = "1.0.1" okhttp = "4.12.0" +coil = "3.0.4" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -48,6 +49,7 @@ androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscree kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/testing/2026_10_03_ocr_variant_experiment.md b/testing/2026_10_03_ocr_variant_experiment.md new file mode 100644 index 0000000..1099360 --- /dev/null +++ b/testing/2026_10_03_ocr_variant_experiment.md @@ -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.