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
This commit is contained in:
ml
2026-10-03 06:44:22 +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.coroutines.android)
implementation(libs.okhttp)
implementation(libs.coil.compose)
debugImplementation(libs.androidx.ui.tooling)
}
@@ -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 }
}
}
}
@@ -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,
@@ -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. */
@@ -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()
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)
@@ -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<String> = listOf(
date.orEmpty(),
@@ -24,6 +19,4 @@ fun Receipt.toExportRow(): List<String> = 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(),
)
@@ -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),
@@ -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
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))
}
@@ -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(),
)
}
@@ -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
}
@@ -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()
}
}
@@ -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()
}
}
}
}
@@ -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))
}
@@ -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<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_settings">Settings</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_choose_gallery">Choose from gallery</string>
@@ -16,8 +17,6 @@
<string name="capture_stage_error">Something went wrong: %1$s</string>
<string name="capture_retry">Retry</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_time">Time (HH:MM:SS)</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_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_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_delete">Delete</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_checking">Checking connection…</string>
<string name="log_copy">Copy log</string>
<string name="log_clear">Clear log</string>
<string name="log_empty">Nothing logged yet</string>
</resources>
+10 -6
View File
@@ -1,12 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Where the FileProvider (see AndroidManifest.xml) is allowed to serve
content:// URIs from. Captured receipt photos are written to the
cache dir by CaptureScreen before being handed to the camera intent
and then uploaded - see ui/capture/CaptureViewModel.kt. -->
content:// URIs from. -->
<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),
shared out via an ACTION_SEND intent - see
ui/overview/OverviewViewModel.kt. -->
shared out via an ACTION_SEND intent - regenerated on every
export, so cache is fine here. -->
<cache-path name="exports" path="exports/" />
</paths>