Add photo export and receipt list sorting/search
build / build (push) Successful in 3m24s

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.
This commit is contained in:
ml
2026-10-04 19:17:55 +02:00
parent 8980f9599b
commit ae2c9be564
6 changed files with 262 additions and 14 deletions
@@ -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<Receipt>, resolver: ContentResolver, out: OutputStream): Int {
val usedNames = mutableSetOf<String>()
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>): 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"
}
@@ -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<Application>()
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<Receipt>, OutputStream) -> Unit, onReady: (Uri) -> Unit) {
val app = getApplication<Application>()
viewModelScope.launch {
@@ -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),
)
}
},
) {
@@ -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(
@@ -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<List<Receipt>> = repository.receipts
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _sort = MutableStateFlow(SortState(SortColumn.DATE, SortDirection.DESCENDING))
val sort: StateFlow<SortState> = _sort.asStateFlow()
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
val receipts: StateFlow<List<Receipt>> = 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<Receipt> {
val base: Comparator<Receipt> = 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<Receipt> =
compareBy { selector(it) ?: Double.MAX_VALUE }
@@ -34,12 +34,16 @@
<string name="receipt_delete">Delete</string>
<string name="overview_empty">No receipts have been scanned yet</string>
<string name="overview_empty_search">No receipts match “%1$s”</string>
<string name="overview_search_placeholder">Search</string>
<string name="overview_search_clear">Clear search</string>
<string name="overview_column_date">Date</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>
<string name="overview_export_ods">Export ODS</string>
<string name="overview_export_photos">Export photos</string>
<string name="overview_export_done">Exported to %1$s</string>
<string name="settings_server_url">Server URL</string>