Compare commits
2 Commits
v0.5.2
..
9bb1844cf4
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bb1844cf4 | |||
| 68e0cd8967 |
@@ -75,5 +75,6 @@ dependencies {
|
|||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
|
implementation(libs.coil.compose)
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import androidx.room.Database
|
|||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.room.RoomDatabase
|
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 class FuelishDatabase : RoomDatabase() {
|
||||||
abstract fun receiptDao(): ReceiptDao
|
abstract fun receiptDao(): ReceiptDao
|
||||||
|
|
||||||
@@ -19,7 +24,10 @@ abstract class FuelishDatabase : RoomDatabase() {
|
|||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
FuelishDatabase::class.java,
|
FuelishDatabase::class.java,
|
||||||
"fuelish.db",
|
"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
|
@Serializable
|
||||||
data class ExtractedReceiptDto(
|
data class ExtractedReceiptDto(
|
||||||
val petrolStation: String? = null,
|
|
||||||
val transactionNumber: String? = null,
|
|
||||||
val date: String? = null,
|
val date: String? = null,
|
||||||
val time: String? = null,
|
val time: String? = null,
|
||||||
val pricePerLitre: Double? = 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
|
* A confirmed receipt, as the user saved it after reviewing the server's
|
||||||
* extraction. This app is the system of record - the server is
|
* extraction. This app is the system of record - the server is
|
||||||
* stateless processing only (see docker-compose.yml / server.py).
|
* 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")
|
@Entity(tableName = "receipts")
|
||||||
data class Receipt(
|
data class Receipt(
|
||||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
@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. */
|
/** DD.MM.YYYY, matching testdata/.../benzin_kosten.ods's ground-truth format. */
|
||||||
val date: String?,
|
val date: String?,
|
||||||
/** HH:MM:SS. */
|
/** 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()
|
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 submitImage(baseUrl: String, imageFile: File): String = api.submitImage(baseUrl, imageFile)
|
||||||
|
|
||||||
suspend fun pollJob(baseUrl: String, jobId: String): JobStatusResponse = api.getJobStatus(baseUrl, jobId)
|
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
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The first 6 columns mirror testdata/.../benzin_kosten.ods's layout
|
* Mirrors testdata/.../benzin_kosten.ods's layout exactly (date, time,
|
||||||
* exactly (date, time, price_per_litre, litres, discount, total_price -
|
* price_per_litre, litres, discount, total_price - see
|
||||||
* see tooling-container/src/compare_ground_truth.py's column order and
|
* tooling-container/src/compare_ground_truth.py's column order and its
|
||||||
* its German-locale decimal-comma / "€"-suffix parsing, which this
|
* German-locale decimal-comma / "€"-suffix parsing, which this
|
||||||
* reproduces on the way out). petrol_station/transaction_number are
|
* reproduces on the way out).
|
||||||
* appended as extra trailing columns - present in this app's data but
|
|
||||||
* not in the original ground-truth sheet.
|
|
||||||
*/
|
*/
|
||||||
val EXPORT_HEADERS = listOf(
|
val EXPORT_HEADERS = listOf("Datum", "Uhrzeit", "Preis/Liter", "Liter", "Rabatt", "Gesamtpreis")
|
||||||
"Datum", "Uhrzeit", "Preis/Liter", "Liter", "Rabatt", "Gesamtpreis",
|
|
||||||
"Tankstelle", "Transaktionsnummer",
|
|
||||||
)
|
|
||||||
|
|
||||||
fun Receipt.toExportRow(): List<String> = listOf(
|
fun Receipt.toExportRow(): List<String> = listOf(
|
||||||
date.orEmpty(),
|
date.orEmpty(),
|
||||||
@@ -24,6 +19,4 @@ fun Receipt.toExportRow(): List<String> = listOf(
|
|||||||
litres?.let { String.format(Locale.GERMANY, "%.2f", it) }.orEmpty(),
|
litres?.let { String.format(Locale.GERMANY, "%.2f", it) }.orEmpty(),
|
||||||
discount?.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(),
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -42,6 +45,11 @@ fun ConnectionStatusBar(viewModel: ConnectionStatusViewModel = viewModel()) {
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
.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),
|
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
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
|
package de.ladkau.fuelish.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
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.material.icons.filled.Menu
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
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.ModalDrawerSheet
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
@@ -17,37 +22,59 @@ import androidx.compose.material3.rememberDrawerState
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
import de.ladkau.fuelish.R
|
import de.ladkau.fuelish.R
|
||||||
import de.ladkau.fuelish.ui.capture.CaptureScreen
|
import de.ladkau.fuelish.ui.capture.CaptureScreen
|
||||||
import de.ladkau.fuelish.ui.log.LogScreen
|
import de.ladkau.fuelish.ui.log.LogScreen
|
||||||
import de.ladkau.fuelish.ui.overview.OverviewScreen
|
import de.ladkau.fuelish.ui.overview.OverviewScreen
|
||||||
|
import de.ladkau.fuelish.ui.receipt.ReceiptDetailScreen
|
||||||
import de.ladkau.fuelish.ui.settings.ConfigScreen
|
import de.ladkau.fuelish.ui.settings.ConfigScreen
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private object Destinations {
|
private object Destinations {
|
||||||
const val CAPTURE = "capture"
|
|
||||||
const val OVERVIEW = "overview"
|
const val OVERVIEW = "overview"
|
||||||
|
const val CAPTURE = "capture"
|
||||||
const val SETTINGS = "settings"
|
const val SETTINGS = "settings"
|
||||||
const val LOG = "log"
|
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
|
* Burger menu = ModalNavigationDrawer, leading to Receipts/Settings/App
|
||||||
* destination) since that's what most opens of the app want to see. */
|
* 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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun FuelishApp() {
|
fun FuelishApp() {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
|
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
|
||||||
|
val exportViewModel: ExportViewModel = viewModel()
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -59,12 +86,6 @@ fun FuelishApp() {
|
|||||||
onClick = { navigateTo(navController, Destinations.OVERVIEW); scope.launch { drawerState.close() } },
|
onClick = { navigateTo(navController, Destinations.OVERVIEW); scope.launch { drawerState.close() } },
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
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(
|
NavigationDrawerItem(
|
||||||
label = { Text(stringResource(R.string.nav_settings)) },
|
label = { Text(stringResource(R.string.nav_settings)) },
|
||||||
selected = currentRoute == Destinations.SETTINGS,
|
selected = currentRoute == Destinations.SETTINGS,
|
||||||
@@ -77,6 +98,25 @@ fun FuelishApp() {
|
|||||||
onClick = { navigateTo(navController, Destinations.LOG); scope.launch { drawerState.close() } },
|
onClick = { navigateTo(navController, Destinations.LOG); scope.launch { drawerState.close() } },
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
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(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.app_name)) },
|
title = { Text(stringResource(R.string.app_name)) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
if (currentRoute in Destinations.DRAWER_DESTINATIONS) {
|
||||||
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.nav_settings))
|
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() },
|
bottomBar = { ConnectionStatusBar() },
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
NavHost(
|
NavHost(
|
||||||
@@ -98,10 +151,21 @@ fun FuelishApp() {
|
|||||||
startDestination = Destinations.OVERVIEW,
|
startDestination = Destinations.OVERVIEW,
|
||||||
modifier = Modifier.padding(innerPadding),
|
modifier = Modifier.padding(innerPadding),
|
||||||
) {
|
) {
|
||||||
composable(Destinations.OVERVIEW) { OverviewScreen() }
|
composable(Destinations.OVERVIEW) {
|
||||||
composable(Destinations.CAPTURE) { CaptureScreen() }
|
OverviewScreen(onReceiptClick = { id -> navController.navigate(Destinations.receiptDetail(id)) })
|
||||||
|
}
|
||||||
|
composable(Destinations.CAPTURE) {
|
||||||
|
CaptureScreen(onSaved = { navController.popBackStack() })
|
||||||
|
}
|
||||||
composable(Destinations.SETTINGS) { ConfigScreen() }
|
composable(Destinations.SETTINGS) { ConfigScreen() }
|
||||||
composable(Destinations.LOG) { LogScreen() }
|
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
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Button
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
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
|
||||||
@@ -25,7 +22,6 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -34,12 +30,12 @@ import androidx.core.content.ContextCompat
|
|||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import de.ladkau.fuelish.R
|
import de.ladkau.fuelish.R
|
||||||
import de.ladkau.fuelish.data.ExtractedReceiptDto
|
|
||||||
import de.ladkau.fuelish.data.JobStatusResponse
|
import de.ladkau.fuelish.data.JobStatusResponse
|
||||||
|
import de.ladkau.fuelish.ui.receipt.ReceiptReviewScreen
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
|
fun CaptureScreen(onSaved: () -> Unit, viewModel: CaptureViewModel = viewModel()) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
@@ -55,7 +51,7 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun launchCamera() {
|
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 file = File(dir, "capture-${System.currentTimeMillis()}.jpg")
|
||||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
pendingFile = file
|
pendingFile = file
|
||||||
@@ -67,16 +63,25 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
|
|||||||
if (granted) launchCamera()
|
if (granted) launchCamera()
|
||||||
}
|
}
|
||||||
|
|
||||||
val pickImageLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
|
val pickImageLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { pickedUri ->
|
||||||
if (uri != null) {
|
if (pickedUri != null) {
|
||||||
val file = copyPickedImageToCache(context, uri)
|
val file = copyPickedImageToCache(context, pickedUri)
|
||||||
if (file != null) viewModel.submit(file, uri)
|
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) {
|
||||||
when (val state = uiState) {
|
is CaptureUiState.Idle -> Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
is CaptureUiState.Idle -> Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
val hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
val hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||||
@@ -94,22 +99,31 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
|
|||||||
Text(stringResource(R.string.capture_choose_gallery))
|
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 -> {
|
is CaptureUiState.Processing -> Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
val (labelRes, progress) = when (state.stage) {
|
val (labelRes, progress) = when (state.stage) {
|
||||||
JobStatusResponse.STATUS_PREPROCESSING -> R.string.capture_stage_preprocessing to 0.45f
|
JobStatusResponse.STATUS_PREPROCESSING -> R.string.capture_stage_preprocessing to 0.45f
|
||||||
JobStatusResponse.STATUS_OCR -> R.string.capture_stage_ocr to 0.7f
|
JobStatusResponse.STATUS_OCR -> R.string.capture_stage_ocr to 0.7f
|
||||||
JobStatusResponse.STATUS_EXTRACTING -> R.string.capture_stage_extracting to 0.9f
|
JobStatusResponse.STATUS_EXTRACTING -> R.string.capture_stage_extracting to 0.9f
|
||||||
else -> R.string.capture_stage_uploading to 0.2f
|
else -> R.string.capture_stage_uploading to 0.2f
|
||||||
}
|
|
||||||
ProgressStage(stringResource(labelRes), progress)
|
|
||||||
}
|
}
|
||||||
|
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))
|
Text(stringResource(R.string.capture_stage_error, state.message))
|
||||||
Button(onClick = { viewModel.reset() }) { Text(stringResource(R.string.capture_retry)) }
|
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()
|
/** 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
|
* 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? {
|
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")
|
val file = File(dir, "picked-${System.currentTimeMillis()}.jpg")
|
||||||
return try {
|
return try {
|
||||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
@@ -143,62 +158,3 @@ private fun ProgressStage(label: String, progress: Float) {
|
|||||||
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
|
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
|
package de.ladkau.fuelish.ui.capture
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import de.ladkau.fuelish.data.ExtractedReceiptDto
|
import de.ladkau.fuelish.data.ExtractedReceiptDto
|
||||||
|
|
||||||
sealed interface CaptureUiState {
|
sealed interface CaptureUiState {
|
||||||
@@ -9,6 +10,6 @@ sealed interface CaptureUiState {
|
|||||||
/** [stage] is one of JobStatusResponse's STATUS_* constants (preprocessing/ocr/extracting). */
|
/** [stage] is one of JobStatusResponse's STATUS_* constants (preprocessing/ocr/extracting). */
|
||||||
data class Processing(val stage: String) : CaptureUiState
|
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
|
data class Error(val message: String) : CaptureUiState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class CaptureViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
when (status.status) {
|
when (status.status) {
|
||||||
JobStatusResponse.STATUS_DONE -> {
|
JobStatusResponse.STATUS_DONE -> {
|
||||||
AppLog.log("Job $jobId done")
|
AppLog.log("Job $jobId done")
|
||||||
_uiState.value = CaptureUiState.Editing(status.result ?: ExtractedReceiptDto())
|
_uiState.value = CaptureUiState.Editing(status.result ?: ExtractedReceiptDto(), pendingImageUri)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
JobStatusResponse.STATUS_ERROR -> {
|
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 {
|
viewModelScope.launch {
|
||||||
repository.save(
|
repository.save(
|
||||||
Receipt(
|
Receipt(
|
||||||
petrolStation = draft.petrolStation,
|
|
||||||
transactionNumber = draft.transactionNumber,
|
|
||||||
date = draft.date,
|
date = draft.date,
|
||||||
time = draft.time,
|
time = draft.time,
|
||||||
pricePerLitre = draft.pricePerLitre,
|
pricePerLitre = draft.pricePerLitre,
|
||||||
@@ -95,6 +93,7 @@ class CaptureViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
AppLog.log("Saved receipt (date=${draft.date}, total=${draft.totalPrice})")
|
AppLog.log("Saved receipt (date=${draft.date}, total=${draft.totalPrice})")
|
||||||
pendingImageUri = null
|
pendingImageUri = null
|
||||||
_uiState.value = CaptureUiState.Idle
|
_uiState.value = CaptureUiState.Idle
|
||||||
|
onSaved()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package de.ladkau.fuelish.ui.log
|
package de.ladkau.fuelish.ui.log
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
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.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -14,7 +17,9 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.ladkau.fuelish.R
|
import de.ladkau.fuelish.R
|
||||||
@@ -26,13 +31,23 @@ import java.util.Locale
|
|||||||
@Composable
|
@Composable
|
||||||
fun LogScreen() {
|
fun LogScreen() {
|
||||||
val entries by AppLog.entries.collectAsState()
|
val entries by AppLog.entries.collectAsState()
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Button(
|
Row(
|
||||||
onClick = { AppLog.clear() },
|
|
||||||
modifier = Modifier.padding(16.dp),
|
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()) {
|
if (entries.isEmpty()) {
|
||||||
@@ -40,17 +55,21 @@ fun LogScreen() {
|
|||||||
} else {
|
} else {
|
||||||
// Newest first - the most recent event (usually what someone
|
// Newest first - the most recent event (usually what someone
|
||||||
// opened this screen to check) is what you want without scrolling.
|
// opened this screen to check) is what you want without scrolling.
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
// SelectionContainer: lets text be selected/copied by hand too,
|
||||||
items(entries.asReversed()) { entry ->
|
// in addition to the "copy everything" button above.
|
||||||
Text(
|
SelectionContainer {
|
||||||
text = formatLogEntry(entry),
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
fontFamily = FontFamily.Monospace,
|
items(entries.asReversed()) { entry ->
|
||||||
style = MaterialTheme.typography.bodySmall,
|
Text(
|
||||||
modifier = Modifier
|
text = formatLogEntry(entry),
|
||||||
.fillMaxWidth()
|
fontFamily = FontFamily.Monospace,
|
||||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
modifier = Modifier
|
||||||
HorizontalDivider()
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.ladkau.fuelish.ui.overview
|
package de.ladkau.fuelish.ui.overview
|
||||||
|
|
||||||
import android.content.Intent
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material3.Button
|
|
||||||
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
|
||||||
@@ -23,7 +21,6 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
@@ -33,25 +30,10 @@ import de.ladkau.fuelish.data.Receipt
|
|||||||
private val DELETE_COLUMN_WIDTH = 48.dp
|
private val DELETE_COLUMN_WIDTH = 48.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OverviewScreen(viewModel: OverviewViewModel = viewModel()) {
|
fun OverviewScreen(onReceiptClick: (Long) -> Unit, viewModel: OverviewViewModel = viewModel()) {
|
||||||
val receipts by viewModel.receipts.collectAsState()
|
val receipts by viewModel.receipts.collectAsState()
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
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()
|
ReceiptTableHeader()
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
||||||
@@ -63,7 +45,11 @@ fun OverviewScreen(viewModel: OverviewViewModel = viewModel()) {
|
|||||||
} else {
|
} else {
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
items(receipts, key = { it.id }) { receipt ->
|
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()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,9 +71,9 @@ private fun ReceiptTableHeader() {
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.overview_column_station),
|
text = stringResource(R.string.overview_column_litres),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
modifier = Modifier.weight(2f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.overview_column_total),
|
text = stringResource(R.string.overview_column_total),
|
||||||
@@ -99,27 +85,19 @@ private fun ReceiptTableHeader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReceiptRow(receipt: Receipt, onDelete: () -> Unit) {
|
private fun ReceiptRow(receipt: Receipt, onClick: () -> Unit, onDelete: () -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(text = receipt.date.orEmpty(), modifier = Modifier.weight(1f))
|
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))
|
Text(text = receipt.totalPrice?.let { "%.2f €".format(it) }.orEmpty(), modifier = Modifier.weight(1f))
|
||||||
IconButton(onClick = onDelete, modifier = Modifier.width(DELETE_COLUMN_WIDTH)) {
|
IconButton(onClick = onDelete, modifier = Modifier.width(DELETE_COLUMN_WIDTH)) {
|
||||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.overview_delete))
|
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
|
package de.ladkau.fuelish.ui.overview
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.net.Uri
|
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import androidx.lifecycle.AndroidViewModel
|
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.exportCsv as writeCsvRows
|
|
||||||
import de.ladkau.fuelish.export.exportOds as writeOdsRows
|
|
||||||
import de.ladkau.fuelish.log.AppLog
|
import de.ladkau.fuelish.log.AppLog
|
||||||
import java.io.File
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
class OverviewViewModel(application: Application) : AndroidViewModel(application) {
|
class OverviewViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
private val repository = ReceiptRepository(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})")
|
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),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
<string name="nav_overview">Receipts</string>
|
<string name="nav_overview">Receipts</string>
|
||||||
<string name="nav_settings">Settings</string>
|
<string name="nav_settings">Settings</string>
|
||||||
<string name="nav_log">App log</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_take_photo">Take photo</string>
|
||||||
<string name="capture_choose_gallery">Choose from gallery</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_stage_error">Something went wrong: %1$s</string>
|
||||||
<string name="capture_retry">Retry</string>
|
<string name="capture_retry">Retry</string>
|
||||||
<string name="capture_save">Save</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_date">Date (DD.MM.YYYY)</string>
|
||||||
<string name="capture_field_time">Time (HH:MM:SS)</string>
|
<string name="capture_field_time">Time (HH:MM:SS)</string>
|
||||||
<string name="capture_field_price_per_litre">Price per litre</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_discount">Discount</string>
|
||||||
<string name="capture_field_total_price">Total price</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_empty">No receipts have been scanned yet</string>
|
||||||
<string name="overview_column_date">Date</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_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>
|
||||||
@@ -46,6 +54,7 @@
|
|||||||
<string name="status_not_configured">No server configured - see Settings</string>
|
<string name="status_not_configured">No server configured - see Settings</string>
|
||||||
<string name="status_checking">Checking connection…</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_clear">Clear log</string>
|
||||||
<string name="log_empty">Nothing logged yet</string>
|
<string name="log_empty">Nothing logged yet</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Where the FileProvider (see AndroidManifest.xml) is allowed to serve
|
<!-- Where the FileProvider (see AndroidManifest.xml) is allowed to serve
|
||||||
content:// URIs from. Captured receipt photos are written to the
|
content:// URIs from. -->
|
||||||
cache dir by CaptureScreen before being handed to the camera intent
|
|
||||||
and then uploaded - see ui/capture/CaptureViewModel.kt. -->
|
|
||||||
<paths>
|
<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),
|
<!-- Exported CSV/ODS files (see export/CsvExporter.kt, OdsExporter.kt),
|
||||||
shared out via an ACTION_SEND intent - see
|
shared out via an ACTION_SEND intent - regenerated on every
|
||||||
ui/overview/OverviewViewModel.kt. -->
|
export, so cache is fine here. -->
|
||||||
<cache-path name="exports" path="exports/" />
|
<cache-path name="exports" path="exports/" />
|
||||||
</paths>
|
</paths>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ kotlinxCoroutines = "1.11.0"
|
|||||||
room = "2.8.5"
|
room = "2.8.5"
|
||||||
splashscreen = "1.0.1"
|
splashscreen = "1.0.1"
|
||||||
okhttp = "4.12.0"
|
okhttp = "4.12.0"
|
||||||
|
coil = "3.0.4"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -48,6 +49,7 @@ androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscree
|
|||||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
||||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
|
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# 3-variant OCR cross-referencing experiment
|
||||||
|
|
||||||
|
Tested whether running Tesseract over three image variants of the same
|
||||||
|
receipt photo (original color photo, plain grayscale, and the existing
|
||||||
|
denoised/contrast-enhanced preprocessing) and asking the extraction LLM
|
||||||
|
to cross-reference all three per field would recover more correct data
|
||||||
|
than the single-OCR-pass baseline, particularly for blurry/low-quality
|
||||||
|
photos.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
Mixed. One more receipt matched overall, two fields improved
|
||||||
|
meaningfully, but the original target field (`litres`) did not improve,
|
||||||
|
and `total_price` regressed.
|
||||||
|
|
||||||
|
| Metric | Baseline (1 OCR pass) | New (3-variant cross-ref) |
|
||||||
|
|---|---|---|
|
||||||
|
| Matched to ground truth | 15/17 | 16/17 |
|
||||||
|
| price_per_litre | 11/15 (73%) | 13/16 (81%) ↑ |
|
||||||
|
| litres | 9/15 (60%) | 9/16 (56%) — unchanged, still worst |
|
||||||
|
| discount | 11/15 (73%) | 15/16 (94%) ↑↑ |
|
||||||
|
| total_price | 12/15 (80%) | 11/16 (69%) ↓ |
|
||||||
|
|
||||||
|
## Root causes identified
|
||||||
|
|
||||||
|
1. **The litres/price-per-litre line is the hardest OCR target**, regardless
|
||||||
|
of variant count. Noise characters (`x2p 02`, `*/p`) precede the real
|
||||||
|
numbers and the litre symbol `l` reads as `1`/`]`/`|` in all three
|
||||||
|
variants alike, so this field stays wrong at the same rate.
|
||||||
|
2. **Three OCR readings don't guarantee the model picks the right one.**
|
||||||
|
On the blurry Jet receipt, the correct total `57,13` survives intact in
|
||||||
|
the original-photo variant, but the model still output `37.13`. Extra
|
||||||
|
(noisy) input sometimes confuses the small 3B model rather than helping
|
||||||
|
it reconcile.
|
||||||
|
3. **`total_price` regressed** — cross-referencing conflicting reads
|
||||||
|
apparently made the model second-guess fields it used to get right from
|
||||||
|
a single clean reading.
|
||||||
|
4. **Severe performance/stability cost** on this 2-core, GPU-less VM:
|
||||||
|
extraction latency became wildly inconsistent (1–19 minutes per receipt
|
||||||
|
vs. seconds before), and one run hit a hard timeout before the request
|
||||||
|
timeout was raised from 300s → 900s. The full 17-receipt batch that used
|
||||||
|
to finish in ~11 minutes now takes well over an hour.
|
||||||
|
|
||||||
|
## Working examples (fully correct — all 4 fields + date matched)
|
||||||
|
|
||||||
|
- `IMG_20260726_095120207`, `095321692`, `095428941`, `095442114`,
|
||||||
|
`095510481`, `095519964` — all Shell receipts with clean, high-contrast
|
||||||
|
OCR text across all three variants.
|
||||||
|
|
||||||
|
## Not-working examples
|
||||||
|
|
||||||
|
- **`IMG_20260525_182252232`** (the blurry Jet receipt that prompted this
|
||||||
|
experiment) — still no date match at all (extracted `13.05.2026` vs. true
|
||||||
|
`15.05.2026`). All three OCR variants scramble the header; even though
|
||||||
|
the correct total `57,13` is legible in the original-photo variant, the
|
||||||
|
model output `37.13` instead.
|
||||||
|
- **`IMG_20260525_182144541`** — worst case, everything wrong except
|
||||||
|
date/time/transaction number: extracted `litres=1.205` (should be ~23),
|
||||||
|
`price_per_litre=1.389` (plausible alone, but paired with nonsense
|
||||||
|
litres), `discount=-0.08` (sign flipped), `total_price=5.33`. All three
|
||||||
|
OCR variants are near-unreadable for this receipt.
|
||||||
|
- **`IMG_20260525_182116766`** — `litres` read correctly (`20.24`, matches
|
||||||
|
the OCR text `"20,24 1 1979 EUR/]"` cleanly) but `price_per_litre` came
|
||||||
|
out as `6.01` instead of `~1.979` — a pure extraction/reconciliation
|
||||||
|
error, not an OCR gap, since the right number is sitting right there in
|
||||||
|
the text.
|
||||||
|
- **`IMG_20260726_095312079`, `095335197`, `095416655`, `095452747`,
|
||||||
|
`095501803`** — all wrong only on `litres`, the recurring weak field.
|
||||||
|
- **`IMG_20260726_095215757`** — wrong on `litres` and `total_price`
|
||||||
|
together; the OCR text for that line is heavily corrupted
|
||||||
|
(`j 2 N N 4) ha 1: Hi A 1.869 EUR/`) in all three variants, so this one
|
||||||
|
genuinely lacks clean signal anywhere.
|
||||||
|
|
||||||
|
## Conclusion / recommendation
|
||||||
|
|
||||||
|
The extra OCR variants help in a few places (`discount`, `price_per_litre`)
|
||||||
|
but don't fix the core problem (`litres` line noise), introduce a new
|
||||||
|
failure mode (the model mis-reconciling conflicting-but-sometimes-correct
|
||||||
|
reads), and cost a lot of latency/stability on this hardware. Leaning
|
||||||
|
toward reverting to single-OCR unless a confidence-triggered fallback
|
||||||
|
(only pull extra variants when the primary reading looks incomplete) is
|
||||||
|
tried instead.
|
||||||
Reference in New Issue
Block a user