From ae2c9be56414c8170f35ab59bff17290903374f2 Mon Sep 17 00:00:00 2001 From: ml Date: Sun, 4 Oct 2026 19:17:55 +0200 Subject: [PATCH] Add photo export and receipt list sorting/search Photo export (hamburger menu) zips every receipt's photo as IMG_YYYYMMDD_HHMMSS.jpg, named from the receipt's own extracted date/time rather than the photo's capture time, with collision-safe naming for same-timestamp receipts. The receipt list's Date/Litres/Total column headers are now tappable to sort (tap again to flip direction), and a search field does a case-insensitive substring match across every exported field, not just the three shown in the list. --- .../de/ladkau/fuelish/export/PhotoExporter.kt | 68 ++++++++++++++ .../de/ladkau/fuelish/ui/ExportViewModel.kt | 20 +++++ .../java/de/ladkau/fuelish/ui/FuelishApp.kt | 9 ++ .../fuelish/ui/overview/OverviewScreen.kt | 88 ++++++++++++++++--- .../fuelish/ui/overview/OverviewViewModel.kt | 87 +++++++++++++++++- android/app/src/main/res/values/strings.xml | 4 + 6 files changed, 262 insertions(+), 14 deletions(-) create mode 100644 android/app/src/main/java/de/ladkau/fuelish/export/PhotoExporter.kt diff --git a/android/app/src/main/java/de/ladkau/fuelish/export/PhotoExporter.kt b/android/app/src/main/java/de/ladkau/fuelish/export/PhotoExporter.kt new file mode 100644 index 0000000..30143b8 --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/export/PhotoExporter.kt @@ -0,0 +1,68 @@ +package de.ladkau.fuelish.export + +import android.content.ContentResolver +import android.net.Uri +import de.ladkau.fuelish.data.Receipt +import java.io.OutputStream +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** IMG_YYYYMMDD_HHMMSS.jpg, built from the receipt's own extracted + * date/time (Receipt.date/time - DD.MM.YYYY / HH:MM:SS), not the photo + * file's own capture timestamp or when it was saved in the app - so the + * exported name reflects what's on the receipt. Null if date or time is + * missing or doesn't parse, since there's then no valid name to build. */ +fun Receipt.photoExportName(): String? { + val d = date ?: return null + val t = time ?: return null + val parsed = try { + SOURCE_FORMAT.parse("$d $t") + } catch (e: ParseException) { + null + } ?: return null + return "IMG_${TARGET_FORMAT.format(parsed)}.jpg" +} + +private val SOURCE_FORMAT = SimpleDateFormat("dd.MM.yyyy HH:mm:ss", Locale.GERMANY) +private val TARGET_FORMAT = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) + +/** Zips every receipt's photo (skipping receipts with no photo, or no + * parseable date/time to name it with) under photoExportName(), appending + * "_2", "_3", ... on a name collision (e.g. two receipts logged with the + * same date and time to the second) so nothing silently overwrites + * another entry. Returns the count actually written. */ +fun exportPhotos(receipts: List, resolver: ContentResolver, out: OutputStream): Int { + val usedNames = mutableSetOf() + var written = 0 + ZipOutputStream(out).use { zip -> + for (receipt in receipts) { + val imageUri = receipt.imageUri ?: continue + val baseName = receipt.photoExportName() ?: continue + val name = uniqueName(baseName, usedNames) + usedNames += name + + val bytes = try { + resolver.openInputStream(Uri.parse(imageUri))?.use { it.readBytes() } + } catch (e: Exception) { + null + } ?: continue + + zip.putNextEntry(ZipEntry(name)) + zip.write(bytes) + zip.closeEntry() + written++ + } + } + return written +} + +private fun uniqueName(baseName: String, used: Set): String { + if (baseName !in used) return baseName + val stem = baseName.removeSuffix(".jpg") + var n = 2 + while ("${stem}_$n.jpg" in used) n++ + return "${stem}_$n.jpg" +} 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 index 9a739fc..7ae733e 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/ui/ExportViewModel.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/ExportViewModel.kt @@ -9,6 +9,7 @@ import de.ladkau.fuelish.data.Receipt import de.ladkau.fuelish.data.ReceiptRepository import de.ladkau.fuelish.export.exportCsv as writeCsvRows import de.ladkau.fuelish.export.exportOds as writeOdsRows +import de.ladkau.fuelish.export.exportPhotos as writePhotosZip import de.ladkau.fuelish.log.AppLog import java.io.File import java.io.OutputStream @@ -27,6 +28,25 @@ class ExportViewModel(application: Application) : AndroidViewModel(application) fun exportOds(onReady: (Uri) -> Unit) = export("fuelish-receipts.ods", ::writeOdsRows, onReady) + /** Zips every receipt's photo as IMG_YYYYMMDD_HHMMSS.jpg (from the + * receipt's own date/time, not the photo's capture time) - see + * export/PhotoExporter.kt's photoExportName(). Receipts with no photo + * or no parseable date/time are skipped, not failed. */ + fun exportPhotos(onReady: (Uri) -> Unit) { + val app = getApplication() + viewModelScope.launch { + val receipts = repository.receipts.first() + val (uri, written) = withContext(Dispatchers.IO) { + val dir = File(app.cacheDir, "exports").apply { mkdirs() } + val file = File(dir, "fuelish-photos.zip") + val count = file.outputStream().use { writePhotosZip(receipts, app.contentResolver, it) } + FileProvider.getUriForFile(app, "${app.packageName}.fileprovider", file) to count + } + AppLog.log("Exported $written of ${receipts.size} photo(s) to fuelish-photos.zip") + onReady(uri) + } + } + private fun export(fileName: String, write: (List, OutputStream) -> Unit, onReady: (Uri) -> Unit) { val app = getApplication() viewModelScope.launch { 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 bcd5018..58087d2 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 @@ -117,6 +117,15 @@ fun FuelishApp() { }, modifier = Modifier.padding(horizontal = 12.dp), ) + NavigationDrawerItem( + label = { Text(stringResource(R.string.overview_export_photos)) }, + selected = false, + onClick = { + scope.launch { drawerState.close() } + exportViewModel.exportPhotos { uri -> shareFile(context, uri, "application/zip") } + }, + modifier = Modifier.padding(horizontal = 12.dp), + ) } }, ) { 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 7cc8110..5c43db7 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 @@ -10,11 +10,16 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -32,14 +37,25 @@ private val DELETE_COLUMN_WIDTH = 48.dp @Composable fun OverviewScreen(onReceiptClick: (Long) -> Unit, viewModel: OverviewViewModel = viewModel()) { val receipts by viewModel.receipts.collectAsState() + val sort by viewModel.sort.collectAsState() + val searchQuery by viewModel.searchQuery.collectAsState() Column(modifier = Modifier.fillMaxSize()) { - ReceiptTableHeader() + SearchField( + query = searchQuery, + onQueryChange = viewModel::setSearchQuery, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + ReceiptTableHeader(sort = sort, onColumnClick = viewModel::setSortColumn) HorizontalDivider() if (receipts.isEmpty()) { Text( - text = stringResource(R.string.overview_empty), + text = if (searchQuery.isBlank()) { + stringResource(R.string.overview_empty) + } else { + stringResource(R.string.overview_empty_search, searchQuery) + }, modifier = Modifier.padding(16.dp), ) } else { @@ -58,32 +74,80 @@ fun OverviewScreen(onReceiptClick: (Long) -> Unit, viewModel: OverviewViewModel } @Composable -private fun ReceiptTableHeader() { +private fun SearchField(query: String, onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + placeholder = { Text(stringResource(R.string.overview_search_placeholder)) }, + leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon(Icons.Filled.Clear, contentDescription = stringResource(R.string.overview_search_clear)) + } + } + }, + singleLine = true, + modifier = modifier.fillMaxWidth(), + ) +} + +@Composable +private fun ReceiptTableHeader(sort: SortState, onColumnClick: (SortColumn) -> Unit) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = stringResource(R.string.overview_column_date), - style = MaterialTheme.typography.labelLarge, + SortableHeaderCell( + label = stringResource(R.string.overview_column_date), + column = SortColumn.DATE, + sort = sort, + onClick = onColumnClick, modifier = Modifier.weight(1f), ) - Text( - text = stringResource(R.string.overview_column_litres), - style = MaterialTheme.typography.labelLarge, + SortableHeaderCell( + label = stringResource(R.string.overview_column_litres), + column = SortColumn.LITRES, + sort = sort, + onClick = onColumnClick, modifier = Modifier.weight(1f), ) - Text( - text = stringResource(R.string.overview_column_total), - style = MaterialTheme.typography.labelLarge, + SortableHeaderCell( + label = stringResource(R.string.overview_column_total), + column = SortColumn.TOTAL_PRICE, + sort = sort, + onClick = onColumnClick, modifier = Modifier.weight(1f), ) Row(modifier = Modifier.width(DELETE_COLUMN_WIDTH)) {} } } +@Composable +private fun SortableHeaderCell( + label: String, + column: SortColumn, + sort: SortState, + onClick: (SortColumn) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.clickable { onClick(column) }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = label, style = MaterialTheme.typography.labelLarge) + if (sort.column == column) { + Icon( + imageVector = if (sort.direction == SortDirection.ASCENDING) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, + contentDescription = null, + modifier = Modifier.padding(start = 2.dp).width(16.dp), + ) + } + } +} + @Composable private fun ReceiptRow(receipt: Receipt, onClick: () -> Unit, onDelete: () -> Unit) { Row( 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 6ca8337..6ea5e5d 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 @@ -5,17 +5,56 @@ 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.toExportRow import de.ladkau.fuelish.log.AppLog +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +/** The overview table's 3 visible/sortable columns (price/litre and + * discount are detail-only, not shown in the list - see + * ReceiptDetailScreen). DATE sorts by the combined date+time instant, + * not the date string alone, since within-day ordering still matters for + * a fuel-cost log. */ +enum class SortColumn { DATE, LITRES, TOTAL_PRICE } + +enum class SortDirection { ASCENDING, DESCENDING } + +data class SortState(val column: SortColumn, val direction: SortDirection) + class OverviewViewModel(application: Application) : AndroidViewModel(application) { private val repository = ReceiptRepository(application) - val receipts: StateFlow> = repository.receipts - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + private val _sort = MutableStateFlow(SortState(SortColumn.DATE, SortDirection.DESCENDING)) + val sort: StateFlow = _sort.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + val receipts: StateFlow> = combine(repository.receipts, _searchQuery, _sort) { all, query, sort -> + all.filter { matchesQuery(it, query) }.sortedWith(comparator(sort)) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun setSearchQuery(query: String) { + _searchQuery.value = query + } + + /** Tapping the already-active column flips its direction; tapping a + * different one switches to it, ascending. */ + fun setSortColumn(column: SortColumn) { + _sort.value = if (_sort.value.column == column) { + _sort.value.copy(direction = _sort.value.direction.flipped()) + } else { + SortState(column, SortDirection.ASCENDING) + } + } fun delete(receipt: Receipt) { viewModelScope.launch { @@ -24,3 +63,47 @@ class OverviewViewModel(application: Application) : AndroidViewModel(application } } } + +private fun SortDirection.flipped() = + if (this == SortDirection.ASCENDING) SortDirection.DESCENDING else SortDirection.ASCENDING + +/** Substring match (case-insensitive) against the same formatted field + * values CSV/ODS export uses (export/ReceiptRow.kt's toExportRow()) - + * "fulltext" across the table's actual fields, not just the three shown + * in the overview list. */ +private fun matchesQuery(receipt: Receipt, query: String): Boolean { + if (query.isBlank()) return true + val needle = query.trim() + return receipt.toExportRow().any { it.contains(needle, ignoreCase = true) } +} + +private val DATE_TIME_FORMAT = SimpleDateFormat("dd.MM.yyyy HH:mm:ss", Locale.GERMANY) + +/** Receipts with no parseable date+time sort to the very end regardless + * of direction, rather than jumping between "first" and "last" depending + * on which way the column is currently sorted. */ +private fun dateTimeSortKey(receipt: Receipt): Long { + val date = receipt.date ?: return Long.MAX_VALUE + val time = receipt.time ?: "00:00:00" + return try { + DATE_TIME_FORMAT.parse("$date $time")?.time ?: Long.MAX_VALUE + } catch (e: ParseException) { + Long.MAX_VALUE + } +} + +private fun comparator(sort: SortState): Comparator { + val base: Comparator = when (sort.column) { + SortColumn.DATE -> compareBy { dateTimeSortKey(it) } + SortColumn.LITRES -> nullsLastDouble { it.litres } + SortColumn.TOTAL_PRICE -> nullsLastDouble { it.totalPrice } + } + return if (sort.direction == SortDirection.DESCENDING) base.reversed() else base +} + +/** compareBy(nullsLast()) alone would put nulls first when .reversed() is + * applied for descending order - build the null-handling into the base + * comparator instead so nulls stay last in both directions, same as + * dateTimeSortKey()'s Long.MAX_VALUE approach. */ +private fun nullsLastDouble(selector: (Receipt) -> Double?): Comparator = + compareBy { selector(it) ?: Double.MAX_VALUE } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 783b120..88d10e1 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -34,12 +34,16 @@ Delete No receipts have been scanned yet + No receipts match ā€œ%1$sā€ + Search + Clear search Date Litres Total Delete Export CSV Export ODS + Export photos Exported to %1$s Server URL