2 Commits

Author SHA1 Message Date
ml ae2c9be564 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.
2026-10-04 19:17:55 +02:00
ml 8980f9599b Make Ollama context size and threads configurable
build / build (push) Successful in 3m23s
2026-10-04 12:45:54 +02:00
9 changed files with 274 additions and 17 deletions
+2 -1
View File
@@ -60,7 +60,8 @@ implausibly - is `12_adaptive_preprocess_ocr.sh` below.
steps are short-lived and stateless (one `docker run` per receipt). steps are short-lived and stateless (one `docker run` per receipt).
`3_extract_data.sh` reaches it via `--network host` at `3_extract_data.sh` reaches it via `--network host` at
`http://127.0.0.1:11434` by default (override with `OLLAMA_URL`/ `http://127.0.0.1:11434` by default (override with `OLLAMA_URL`/
`OLLAMA_MODEL`). `OLLAMA_MODEL`; `OLLAMA_NUM_CTX` (default 3072) and `OLLAMA_NUM_THREAD`
(default: Ollama's) set the request options).
- `build-image.sh` / `upload-image.sh` - build/push the - `build-image.sh` / `upload-image.sh` - build/push the
`tooling-container/` image, tagged from `tooling-container/VERSION`. `tooling-container/` image, tagged from `tooling-container/VERSION`.
Image name defaults to `fuelish/tools`, overridable via Image name defaults to `fuelish/tools`, overridable via
@@ -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.data.ReceiptRepository
import de.ladkau.fuelish.export.exportCsv as writeCsvRows import de.ladkau.fuelish.export.exportCsv as writeCsvRows
import de.ladkau.fuelish.export.exportOds as writeOdsRows import de.ladkau.fuelish.export.exportOds as writeOdsRows
import de.ladkau.fuelish.export.exportPhotos as writePhotosZip
import de.ladkau.fuelish.log.AppLog import de.ladkau.fuelish.log.AppLog
import java.io.File import java.io.File
import java.io.OutputStream 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) 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) { private fun export(fileName: String, write: (List<Receipt>, OutputStream) -> Unit, onReady: (Uri) -> Unit) {
val app = getApplication<Application>() val app = getApplication<Application>()
viewModelScope.launch { viewModelScope.launch {
@@ -117,6 +117,15 @@ fun FuelishApp() {
}, },
modifier = Modifier.padding(horizontal = 12.dp), 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Delete 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.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -32,14 +37,25 @@ private val DELETE_COLUMN_WIDTH = 48.dp
@Composable @Composable
fun OverviewScreen(onReceiptClick: (Long) -> Unit, viewModel: OverviewViewModel = viewModel()) { fun OverviewScreen(onReceiptClick: (Long) -> Unit, viewModel: OverviewViewModel = viewModel()) {
val receipts by viewModel.receipts.collectAsState() val receipts by viewModel.receipts.collectAsState()
val sort by viewModel.sort.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
Column(modifier = Modifier.fillMaxSize()) { 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() HorizontalDivider()
if (receipts.isEmpty()) { if (receipts.isEmpty()) {
Text( 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), modifier = Modifier.padding(16.dp),
) )
} else { } else {
@@ -58,32 +74,80 @@ fun OverviewScreen(onReceiptClick: (Long) -> Unit, viewModel: OverviewViewModel
} }
@Composable @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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( SortableHeaderCell(
text = stringResource(R.string.overview_column_date), label = stringResource(R.string.overview_column_date),
style = MaterialTheme.typography.labelLarge, column = SortColumn.DATE,
sort = sort,
onClick = onColumnClick,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
Text( SortableHeaderCell(
text = stringResource(R.string.overview_column_litres), label = stringResource(R.string.overview_column_litres),
style = MaterialTheme.typography.labelLarge, column = SortColumn.LITRES,
sort = sort,
onClick = onColumnClick,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
Text( SortableHeaderCell(
text = stringResource(R.string.overview_column_total), label = stringResource(R.string.overview_column_total),
style = MaterialTheme.typography.labelLarge, column = SortColumn.TOTAL_PRICE,
sort = sort,
onClick = onColumnClick,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
Row(modifier = Modifier.width(DELETE_COLUMN_WIDTH)) {} 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 @Composable
private fun ReceiptRow(receipt: Receipt, onClick: () -> Unit, onDelete: () -> Unit) { private fun ReceiptRow(receipt: Receipt, onClick: () -> Unit, onDelete: () -> Unit) {
Row( Row(
@@ -5,17 +5,56 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import de.ladkau.fuelish.data.Receipt import de.ladkau.fuelish.data.Receipt
import de.ladkau.fuelish.data.ReceiptRepository import de.ladkau.fuelish.data.ReceiptRepository
import de.ladkau.fuelish.export.toExportRow
import de.ladkau.fuelish.log.AppLog 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.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch 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) { class OverviewViewModel(application: Application) : AndroidViewModel(application) {
private val repository = ReceiptRepository(application) private val repository = ReceiptRepository(application)
val receipts: StateFlow<List<Receipt>> = repository.receipts private val _sort = MutableStateFlow(SortState(SortColumn.DATE, SortDirection.DESCENDING))
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) 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) { fun delete(receipt: Receipt) {
viewModelScope.launch { 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="receipt_delete">Delete</string>
<string name="overview_empty">No receipts have been scanned yet</string> <string name="overview_empty">No receipts have been scanned yet</string>
<string name="overview_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_date">Date</string>
<string name="overview_column_litres">Litres</string> <string name="overview_column_litres">Litres</string>
<string name="overview_column_total">Total</string> <string name="overview_column_total">Total</string>
<string name="overview_delete">Delete</string> <string name="overview_delete">Delete</string>
<string name="overview_export_csv">Export CSV</string> <string name="overview_export_csv">Export CSV</string>
<string name="overview_export_ods">Export ODS</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="overview_export_done">Exported to %1$s</string>
<string name="settings_server_url">Server URL</string> <string name="settings_server_url">Server URL</string>
+1 -1
View File
@@ -1 +1 @@
0.1.0 0.1.1
+9 -1
View File
@@ -10,6 +10,11 @@ import requests
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434/api/chat") OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434/api/chat")
MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:3b") MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:3b")
# Ollama restarts its runner when a request's context size or thread count
# differs from the loaded one; set these to match other clients of a shared
# Ollama. OLLAMA_NUM_THREAD unset = Ollama's default.
NUM_CTX = int(os.environ.get("OLLAMA_NUM_CTX", "3072"))
NUM_THREAD = os.environ.get("OLLAMA_NUM_THREAD")
SCHEMA = { SCHEMA = {
"type": "object", "type": "object",
@@ -57,6 +62,9 @@ Respond with ONLY the JSON object, no other text.
def structure(ocr_text: str) -> dict: def structure(ocr_text: str) -> dict:
options = {"temperature": 0, "num_ctx": NUM_CTX}
if NUM_THREAD:
options["num_thread"] = int(NUM_THREAD)
payload = { payload = {
"model": MODEL, "model": MODEL,
"messages": [ "messages": [
@@ -64,7 +72,7 @@ def structure(ocr_text: str) -> dict:
], ],
"format": SCHEMA, "format": SCHEMA,
"stream": False, "stream": False,
"options": {"temperature": 0, "num_ctx": 3072}, "options": options,
} }
start = time.time() start = time.time()