Add HTTP API server, Android app, and CI build/publish pipeline
build / build (push) Failing after 14s
build / build (push) Failing after 14s
- docker-compose.yml + tooling-container/src/server.py: serve
preprocess/OCR/extract over an async job API (POST /jobs, GET
/jobs/{id}) instead of manual host-script runs per receipt
- android/: Kotlin/Compose app - capture a receipt photo, poll job
progress, review/edit extracted fields, save locally (Room), export
to CSV/ODS
- .gitea/workflows/build.yml: build the debug APK and publish it to
dl.ladkau.de on push
- build-image.sh: also pull ollama/ollama so all pipeline images are
ready after one script
- add project logo (also used for the app's launcher icon/splash)
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".FuelishApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@style/Theme.Fuelish.Splash">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Fuelish.Splash">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package de.ladkau.fuelish
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class FuelishApplication : Application()
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.ladkau.fuelish
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import de.ladkau.fuelish.ui.FuelishApp
|
||||
import de.ladkau.fuelish.ui.theme.FuelishTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
FuelishTheme {
|
||||
FuelishApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.ladkau.fuelish.data
|
||||
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNamingStrategy
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
|
||||
/** Talks to tooling-container/src/server.py's two endpoints. Every call
|
||||
* takes the server's base URL explicitly (from [ConfigRepository]) rather
|
||||
* than storing it, since the user can change it from Settings mid-session. */
|
||||
class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||
}
|
||||
|
||||
suspend fun submitImage(baseUrl: String, imageFile: File): String = withContext(Dispatchers.IO) {
|
||||
val body = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("image", imageFile.name, imageFile.asRequestBody("image/jpeg".toMediaType()))
|
||||
.build()
|
||||
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs").post(body).build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) throw IOException("upload failed: HTTP ${response.code}")
|
||||
val text = response.body?.string().orEmpty()
|
||||
json.decodeFromString(CreateJobResponse.serializer(), text).jobId
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getJobStatus(baseUrl: String, jobId: String): JobStatusResponse = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs/$jobId").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) throw IOException("status check failed: HTTP ${response.code}")
|
||||
val text = response.body?.string().orEmpty()
|
||||
json.decodeFromString(JobStatusResponse.serializer(), text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package de.ladkau.fuelish.data
|
||||
|
||||
/**
|
||||
* Everything the app needs that isn't a saved receipt: just the fuelish
|
||||
* server's base URL (e.g. "http://192.168.1.10:8080") right now.
|
||||
* Persisted via Preferences DataStore, see [ConfigRepository].
|
||||
*/
|
||||
data class AppConfig(
|
||||
val serverUrl: String = "",
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.ladkau.fuelish.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.configDataStore: DataStore<Preferences> by preferencesDataStore(name = "fuelish_config")
|
||||
|
||||
/** Preferences DataStore-backed persistence for [AppConfig]. */
|
||||
class ConfigRepository(private val context: Context) {
|
||||
private object Keys {
|
||||
val SERVER_URL = stringPreferencesKey("server_url")
|
||||
}
|
||||
|
||||
val config: Flow<AppConfig> = context.configDataStore.data.map { prefs ->
|
||||
AppConfig(serverUrl = prefs[Keys.SERVER_URL] ?: "")
|
||||
}
|
||||
|
||||
suspend fun save(config: AppConfig) {
|
||||
context.configDataStore.edit { prefs ->
|
||||
prefs[Keys.SERVER_URL] = config.serverUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.ladkau.fuelish.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(entities = [Receipt::class], version = 1, exportSchema = false)
|
||||
abstract class FuelishDatabase : RoomDatabase() {
|
||||
abstract fun receiptDao(): ReceiptDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: FuelishDatabase? = null
|
||||
|
||||
fun get(context: Context): FuelishDatabase =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
FuelishDatabase::class.java,
|
||||
"fuelish.db",
|
||||
).build().also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.ladkau.fuelish.data
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Mirrors tooling-container/src/server.py's POST /jobs response. */
|
||||
@Serializable
|
||||
data class CreateJobResponse(val jobId: String)
|
||||
|
||||
/** Mirrors tooling-container/src/server.py's GET /jobs/{id} response. */
|
||||
@Serializable
|
||||
data class JobStatusResponse(
|
||||
val status: String,
|
||||
val result: ExtractedReceiptDto? = null,
|
||||
val error: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
const val STATUS_QUEUED = "queued"
|
||||
const val STATUS_PREPROCESSING = "preprocessing"
|
||||
const val STATUS_OCR = "ocr"
|
||||
const val STATUS_EXTRACTING = "extracting"
|
||||
const val STATUS_DONE = "done"
|
||||
const val STATUS_ERROR = "error"
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirrors tooling-container/src/extract.py's SCHEMA. */
|
||||
@Serializable
|
||||
data class ExtractedReceiptDto(
|
||||
val petrolStation: String? = null,
|
||||
val transactionNumber: String? = null,
|
||||
val date: String? = null,
|
||||
val time: String? = null,
|
||||
val pricePerLitre: Double? = null,
|
||||
val litres: Double? = null,
|
||||
val discount: Double? = null,
|
||||
val totalPrice: Double? = null,
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.ladkau.fuelish.data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* A confirmed receipt, as the user saved it after reviewing the server's
|
||||
* extraction. This app is the system of record - the server is
|
||||
* stateless processing only (see docker-compose.yml / server.py).
|
||||
*/
|
||||
@Entity(tableName = "receipts")
|
||||
data class Receipt(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val petrolStation: String?,
|
||||
val transactionNumber: String?,
|
||||
/** DD.MM.YYYY, matching testdata/.../benzin_kosten.ods's ground-truth format. */
|
||||
val date: String?,
|
||||
/** HH:MM:SS. */
|
||||
val time: String?,
|
||||
val pricePerLitre: Double?,
|
||||
val litres: Double?,
|
||||
val discount: Double?,
|
||||
val totalPrice: Double?,
|
||||
/** content:// or file:// URI of the captured photo, kept for reference - null if unavailable. */
|
||||
val imageUri: String?,
|
||||
val createdAtEpochMillis: Long,
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.ladkau.fuelish.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface ReceiptDao {
|
||||
@Query("SELECT * FROM receipts ORDER BY date DESC, createdAtEpochMillis DESC")
|
||||
fun observeAll(): Flow<List<Receipt>>
|
||||
|
||||
@Query("SELECT * FROM receipts WHERE id = :id")
|
||||
suspend fun getById(id: Long): Receipt?
|
||||
|
||||
@Insert
|
||||
suspend fun insert(receipt: Receipt): Long
|
||||
|
||||
@Update
|
||||
suspend fun update(receipt: Receipt)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(receipt: Receipt)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.ladkau.fuelish.data
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/** Wraps the local Room store (the system of record for confirmed
|
||||
* receipts) and the [ApiClient] (stateless per-receipt processing) behind
|
||||
* one entry point for the ui layer. */
|
||||
class ReceiptRepository(context: Context) {
|
||||
private val dao = FuelishDatabase.get(context).receiptDao()
|
||||
private val api = ApiClient()
|
||||
|
||||
val receipts: Flow<List<Receipt>> = dao.observeAll()
|
||||
|
||||
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 save(receipt: Receipt): Long = dao.insert(receipt)
|
||||
|
||||
suspend fun update(receipt: Receipt) = dao.update(receipt)
|
||||
|
||||
suspend fun delete(receipt: Receipt) = dao.delete(receipt)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.ladkau.fuelish.export
|
||||
|
||||
import de.ladkau.fuelish.data.Receipt
|
||||
import java.io.OutputStream
|
||||
|
||||
/** ";"-delimited, not ",": our cells use a German decimal comma (see
|
||||
* [toExportRow]), which would otherwise collide with a comma delimiter -
|
||||
* ";" is also what German-locale Excel/LibreOffice expect by default. */
|
||||
private const val DELIMITER = ";"
|
||||
private val UTF8_BOM = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte())
|
||||
|
||||
fun exportCsv(receipts: List<Receipt>, out: OutputStream) {
|
||||
out.write(UTF8_BOM)
|
||||
out.write(csvLine(EXPORT_HEADERS).toByteArray(Charsets.UTF_8))
|
||||
for (receipt in receipts) {
|
||||
out.write(csvLine(receipt.toExportRow()).toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
}
|
||||
|
||||
private fun csvLine(fields: List<String>): String =
|
||||
fields.joinToString(DELIMITER) { escapeCsvField(it) } + "\r\n"
|
||||
|
||||
private fun escapeCsvField(field: String): String =
|
||||
if (field.contains(DELIMITER) || field.contains('"') || field.contains('\n')) {
|
||||
"\"" + field.replace("\"", "\"\"") + "\""
|
||||
} else {
|
||||
field
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package de.ladkau.fuelish.export
|
||||
|
||||
import de.ladkau.fuelish.data.Receipt
|
||||
import java.io.OutputStream
|
||||
import java.util.zip.CRC32
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
private const val MIMETYPE = "application/vnd.oasis.opendocument.spreadsheet"
|
||||
|
||||
/**
|
||||
* A minimal hand-rolled ODF spreadsheet writer - no dependency needed,
|
||||
* since an .ods is just a zip of a few fixed XML files. Writes only what
|
||||
* testdata/.../benzin_kosten.ods's own reader (compare_ground_truth.py,
|
||||
* via odfpy) needs: one table, string-typed cells (matching how that
|
||||
* ground-truth sheet itself stores formatted values - see
|
||||
* [toExportRow]'s doc comment), no styles/formulas.
|
||||
*/
|
||||
fun exportOds(receipts: List<Receipt>, out: OutputStream) {
|
||||
ZipOutputStream(out).use { zip ->
|
||||
// Must be the first entry, stored uncompressed with no extra
|
||||
// fields, so ODF-aware tools can sniff the file type without
|
||||
// inflating - the format spec requires this.
|
||||
val mimetypeBytes = MIMETYPE.toByteArray(Charsets.US_ASCII)
|
||||
val mimetypeEntry = ZipEntry("mimetype").apply {
|
||||
method = ZipEntry.STORED
|
||||
size = mimetypeBytes.size.toLong()
|
||||
compressedSize = mimetypeBytes.size.toLong()
|
||||
crc = CRC32().apply { update(mimetypeBytes) }.value
|
||||
}
|
||||
zip.putNextEntry(mimetypeEntry)
|
||||
zip.write(mimetypeBytes)
|
||||
zip.closeEntry()
|
||||
|
||||
zip.putNextEntry(ZipEntry("META-INF/manifest.xml"))
|
||||
zip.write(manifestXml().toByteArray(Charsets.UTF_8))
|
||||
zip.closeEntry()
|
||||
|
||||
zip.putNextEntry(ZipEntry("content.xml"))
|
||||
zip.write(contentXml(receipts).toByteArray(Charsets.UTF_8))
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
|
||||
private fun manifestXml(): String = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">
|
||||
<manifest:file-entry manifest:full-path="/" manifest:version="1.2" manifest:media-type="$MIMETYPE"/>
|
||||
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
|
||||
</manifest:manifest>
|
||||
""".trimIndent()
|
||||
|
||||
private fun contentXml(receipts: List<Receipt>): String {
|
||||
val rows = buildString {
|
||||
append(tableRow(EXPORT_HEADERS))
|
||||
for (receipt in receipts) {
|
||||
append(tableRow(receipt.toExportRow()))
|
||||
}
|
||||
}
|
||||
return """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-content
|
||||
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
||||
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
|
||||
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
|
||||
office:version="1.2">
|
||||
<office:body>
|
||||
<office:spreadsheet>
|
||||
<table:table table:name="Receipts">
|
||||
$rows
|
||||
</table:table>
|
||||
</office:spreadsheet>
|
||||
</office:body>
|
||||
</office:document-content>
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun tableRow(cells: List<String>): String = buildString {
|
||||
append("<table:table-row>")
|
||||
for (cell in cells) {
|
||||
append("<table:table-cell office:value-type=\"string\"><text:p>")
|
||||
append(escapeXml(cell))
|
||||
append("</text:p></table:table-cell>")
|
||||
}
|
||||
append("</table:table-row>")
|
||||
}
|
||||
|
||||
private fun escapeXml(text: String): String = text
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.ladkau.fuelish.export
|
||||
|
||||
import de.ladkau.fuelish.data.Receipt
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The first 6 columns mirror testdata/.../benzin_kosten.ods's layout
|
||||
* exactly (date, time, price_per_litre, litres, discount, total_price -
|
||||
* see tooling-container/src/compare_ground_truth.py's column order and
|
||||
* its German-locale decimal-comma / "€"-suffix parsing, which this
|
||||
* reproduces on the way out). petrol_station/transaction_number are
|
||||
* appended as extra trailing columns - present in this app's data but
|
||||
* not in the original ground-truth sheet.
|
||||
*/
|
||||
val EXPORT_HEADERS = listOf(
|
||||
"Datum", "Uhrzeit", "Preis/Liter", "Liter", "Rabatt", "Gesamtpreis",
|
||||
"Tankstelle", "Transaktionsnummer",
|
||||
)
|
||||
|
||||
fun Receipt.toExportRow(): List<String> = listOf(
|
||||
date.orEmpty(),
|
||||
time.orEmpty(),
|
||||
pricePerLitre?.let { String.format(Locale.GERMANY, "%.3f", it) }.orEmpty(),
|
||||
litres?.let { String.format(Locale.GERMANY, "%.2f", it) }.orEmpty(),
|
||||
discount?.let { String.format(Locale.GERMANY, "%.2f €", it) }.orEmpty(),
|
||||
totalPrice?.let { String.format(Locale.GERMANY, "%.2f €", it) }.orEmpty(),
|
||||
petrolStation.orEmpty(),
|
||||
transactionNumber.orEmpty(),
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
package de.ladkau.fuelish.ui
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import de.ladkau.fuelish.R
|
||||
import de.ladkau.fuelish.ui.capture.CaptureScreen
|
||||
import de.ladkau.fuelish.ui.overview.OverviewScreen
|
||||
import de.ladkau.fuelish.ui.settings.ConfigScreen
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private object Destinations {
|
||||
const val CAPTURE = "capture"
|
||||
const val OVERVIEW = "overview"
|
||||
const val SETTINGS = "settings"
|
||||
}
|
||||
|
||||
/** Burger menu = ModalNavigationDrawer, leading to Scan/Receipts/Settings -
|
||||
* the app's single navigation host. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FuelishApp() {
|
||||
val navController = rememberNavController()
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet {
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(R.string.nav_capture)) },
|
||||
selected = currentRoute == Destinations.CAPTURE,
|
||||
onClick = { navigateTo(navController, Destinations.CAPTURE); scope.launch { drawerState.close() } },
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(R.string.nav_overview)) },
|
||||
selected = currentRoute == Destinations.OVERVIEW,
|
||||
onClick = { navigateTo(navController, Destinations.OVERVIEW); scope.launch { drawerState.close() } },
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(R.string.nav_settings)) },
|
||||
selected = currentRoute == Destinations.SETTINGS,
|
||||
onClick = { navigateTo(navController, Destinations.SETTINGS); scope.launch { drawerState.close() } },
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.app_name)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.nav_settings))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Destinations.CAPTURE,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
) {
|
||||
composable(Destinations.CAPTURE) { CaptureScreen() }
|
||||
composable(Destinations.OVERVIEW) { OverviewScreen() }
|
||||
composable(Destinations.SETTINGS) { ConfigScreen() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateTo(navController: NavHostController, route: String) {
|
||||
navController.navigate(route) {
|
||||
popUpTo(navController.graph.startDestinationId) { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package de.ladkau.fuelish.ui.capture
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import de.ladkau.fuelish.R
|
||||
import de.ladkau.fuelish.data.ExtractedReceiptDto
|
||||
import de.ladkau.fuelish.data.JobStatusResponse
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
var pendingFile by remember { mutableStateOf<File?>(null) }
|
||||
var pendingUri by remember { mutableStateOf<Uri?>(null) }
|
||||
|
||||
val takePictureLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
||||
val file = pendingFile
|
||||
val uri = pendingUri
|
||||
if (success && file != null && uri != null) {
|
||||
viewModel.submit(file, uri)
|
||||
}
|
||||
}
|
||||
|
||||
fun launchCamera() {
|
||||
val dir = File(context.cacheDir, "captures").apply { mkdirs() }
|
||||
val file = File(dir, "capture-${System.currentTimeMillis()}.jpg")
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
pendingFile = file
|
||||
pendingUri = uri
|
||||
takePictureLauncher.launch(uri)
|
||||
}
|
||||
|
||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) launchCamera()
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
when (val state = uiState) {
|
||||
is CaptureUiState.Idle -> Button(
|
||||
onClick = {
|
||||
val hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (hasPermission) launchCamera() else cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.capture_take_photo))
|
||||
}
|
||||
|
||||
is CaptureUiState.Uploading -> ProgressStage(stringResource(R.string.capture_stage_uploading), 0.2f)
|
||||
|
||||
is CaptureUiState.Processing -> {
|
||||
val (labelRes, progress) = when (state.stage) {
|
||||
JobStatusResponse.STATUS_PREPROCESSING -> R.string.capture_stage_preprocessing to 0.45f
|
||||
JobStatusResponse.STATUS_OCR -> R.string.capture_stage_ocr to 0.7f
|
||||
JobStatusResponse.STATUS_EXTRACTING -> R.string.capture_stage_extracting to 0.9f
|
||||
else -> R.string.capture_stage_uploading to 0.2f
|
||||
}
|
||||
ProgressStage(stringResource(labelRes), progress)
|
||||
}
|
||||
|
||||
is CaptureUiState.Editing -> EditForm(draft = state.draft, onSave = { viewModel.save(it) })
|
||||
|
||||
is CaptureUiState.Error -> Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(stringResource(R.string.capture_stage_error, state.message))
|
||||
Button(onClick = { viewModel.reset() }) { Text(stringResource(R.string.capture_retry)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressStage(label: String, progress: Float) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(label)
|
||||
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(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.ladkau.fuelish.ui.capture
|
||||
|
||||
import de.ladkau.fuelish.data.ExtractedReceiptDto
|
||||
|
||||
sealed interface CaptureUiState {
|
||||
data object Idle : CaptureUiState
|
||||
data object Uploading : CaptureUiState
|
||||
|
||||
/** [stage] is one of JobStatusResponse's STATUS_* constants (preprocessing/ocr/extracting). */
|
||||
data class Processing(val stage: String) : CaptureUiState
|
||||
|
||||
data class Editing(val draft: ExtractedReceiptDto) : CaptureUiState
|
||||
data class Error(val message: String) : CaptureUiState
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package de.ladkau.fuelish.ui.capture
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import de.ladkau.fuelish.data.ConfigRepository
|
||||
import de.ladkau.fuelish.data.ExtractedReceiptDto
|
||||
import de.ladkau.fuelish.data.JobStatusResponse
|
||||
import de.ladkau.fuelish.data.Receipt
|
||||
import de.ladkau.fuelish.data.ReceiptRepository
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val POLL_INTERVAL_MILLIS = 1_500L
|
||||
|
||||
class CaptureViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val repository = ReceiptRepository(application)
|
||||
private val configRepository = ConfigRepository(application)
|
||||
|
||||
private val _uiState = MutableStateFlow<CaptureUiState>(CaptureUiState.Idle)
|
||||
val uiState: StateFlow<CaptureUiState> = _uiState.asStateFlow()
|
||||
|
||||
// The captured photo's local URI, kept alongside the receipt once saved
|
||||
// (see Receipt.imageUri) - set right before submit(), cleared on save()/reset().
|
||||
private var pendingImageUri: Uri? = null
|
||||
|
||||
fun submit(imageFile: File, imageUri: Uri) {
|
||||
pendingImageUri = imageUri
|
||||
viewModelScope.launch {
|
||||
_uiState.value = CaptureUiState.Uploading
|
||||
try {
|
||||
val baseUrl = configRepository.config.first().serverUrl
|
||||
if (baseUrl.isBlank()) {
|
||||
_uiState.value = CaptureUiState.Error("Set the server URL in Settings first")
|
||||
return@launch
|
||||
}
|
||||
val jobId = repository.submitImage(baseUrl, imageFile)
|
||||
pollUntilDone(baseUrl, jobId)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = CaptureUiState.Error(e.message ?: "unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pollUntilDone(baseUrl: String, jobId: String) {
|
||||
while (true) {
|
||||
val status = repository.pollJob(baseUrl, jobId)
|
||||
when (status.status) {
|
||||
JobStatusResponse.STATUS_DONE -> {
|
||||
_uiState.value = CaptureUiState.Editing(status.result ?: ExtractedReceiptDto())
|
||||
return
|
||||
}
|
||||
JobStatusResponse.STATUS_ERROR -> {
|
||||
_uiState.value = CaptureUiState.Error(status.error ?: "processing failed")
|
||||
return
|
||||
}
|
||||
else -> {
|
||||
_uiState.value = CaptureUiState.Processing(status.status)
|
||||
delay(POLL_INTERVAL_MILLIS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun save(draft: ExtractedReceiptDto) {
|
||||
viewModelScope.launch {
|
||||
repository.save(
|
||||
Receipt(
|
||||
petrolStation = draft.petrolStation,
|
||||
transactionNumber = draft.transactionNumber,
|
||||
date = draft.date,
|
||||
time = draft.time,
|
||||
pricePerLitre = draft.pricePerLitre,
|
||||
litres = draft.litres,
|
||||
discount = draft.discount,
|
||||
totalPrice = draft.totalPrice,
|
||||
imageUri = pendingImageUri?.toString(),
|
||||
createdAtEpochMillis = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
pendingImageUri = null
|
||||
_uiState.value = CaptureUiState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
pendingImageUri = null
|
||||
_uiState.value = CaptureUiState.Idle
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package de.ladkau.fuelish.ui.overview
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import de.ladkau.fuelish.R
|
||||
import de.ladkau.fuelish.data.Receipt
|
||||
|
||||
@Composable
|
||||
fun OverviewScreen(viewModel: OverviewViewModel = viewModel()) {
|
||||
val receipts by viewModel.receipts.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Button(onClick = { viewModel.exportCsv { uri -> shareFile(context, uri, "text/csv") } }) {
|
||||
Text(stringResource(R.string.overview_export_csv))
|
||||
}
|
||||
Button(onClick = { viewModel.exportOds { uri -> shareFile(context, uri, "application/vnd.oasis.opendocument.spreadsheet") } }) {
|
||||
Text(stringResource(R.string.overview_export_ods))
|
||||
}
|
||||
}
|
||||
|
||||
if (receipts.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.overview_empty),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(receipts, key = { it.id }) { receipt ->
|
||||
ReceiptRow(receipt = receipt, onDelete = { viewModel.delete(receipt) })
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReceiptRow(receipt: Receipt, onDelete: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text(text = receipt.date.orEmpty())
|
||||
Text(text = listOfNotNull(receipt.petrolStation, receipt.totalPrice?.let { "%.2f €".format(it) }).joinToString(" · "))
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package de.ladkau.fuelish.ui.overview
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import de.ladkau.fuelish.data.Receipt
|
||||
import de.ladkau.fuelish.data.ReceiptRepository
|
||||
import de.ladkau.fuelish.export.exportCsv as writeCsvRows
|
||||
import de.ladkau.fuelish.export.exportOds as writeOdsRows
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class OverviewViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val repository = ReceiptRepository(application)
|
||||
|
||||
val receipts: StateFlow<List<Receipt>> = repository.receipts
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
fun delete(receipt: Receipt) {
|
||||
viewModelScope.launch { repository.delete(receipt) }
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
onReady(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.ladkau.fuelish.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import de.ladkau.fuelish.R
|
||||
|
||||
@Composable
|
||||
fun ConfigScreen(viewModel: ConfigViewModel = viewModel()) {
|
||||
val config by viewModel.config.collectAsState()
|
||||
val isDirty by viewModel.isDirty.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = config.serverUrl,
|
||||
onValueChange = { viewModel.update { c -> c.copy(serverUrl = it) } },
|
||||
label = { Text(stringResource(R.string.settings_server_url)) },
|
||||
placeholder = { Text(stringResource(R.string.settings_server_url_hint)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(onClick = { viewModel.save() }, enabled = isDirty) {
|
||||
Text(stringResource(R.string.capture_save))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package de.ladkau.fuelish.ui.settings
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import de.ladkau.fuelish.data.AppConfig
|
||||
import de.ladkau.fuelish.data.ConfigRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ConfigViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val configRepository = ConfigRepository(application)
|
||||
|
||||
// A local editable draft, seeded once from the persisted config below -
|
||||
// deliberately not a live collection of configRepository.config, so
|
||||
// in-progress edits aren't clobbered by DataStore's own emission of the
|
||||
// write this same screen is about to make on save().
|
||||
private val _config = MutableStateFlow(AppConfig())
|
||||
val config: StateFlow<AppConfig> = _config.asStateFlow()
|
||||
|
||||
private val _savedConfig = MutableStateFlow(AppConfig())
|
||||
|
||||
val isDirty: StateFlow<Boolean> = combine(_config, _savedConfig) { draft, saved -> draft != saved }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val loaded = configRepository.config.first()
|
||||
_config.value = loaded
|
||||
_savedConfig.value = loaded
|
||||
}
|
||||
}
|
||||
|
||||
fun update(transform: (AppConfig) -> AppConfig) {
|
||||
_config.value = transform(_config.value)
|
||||
}
|
||||
|
||||
fun save() {
|
||||
viewModelScope.launch {
|
||||
val toSave = _config.value
|
||||
configRepository.save(toSave)
|
||||
_savedConfig.value = toSave
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.ladkau.fuelish.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val LightColors = lightColorScheme()
|
||||
private val DarkColors = darkColorScheme()
|
||||
|
||||
@Composable
|
||||
fun FuelishTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
darkTheme -> DarkColors
|
||||
else -> LightColors
|
||||
}
|
||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Matches the white background baked into logo.png, so the
|
||||
generated launcher icon / splash screen sit on the same
|
||||
background the source art was designed against. -->
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
<color name="splash_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">fuelish</string>
|
||||
|
||||
<string name="nav_capture">Scan receipt</string>
|
||||
<string name="nav_overview">Receipts</string>
|
||||
<string name="nav_settings">Settings</string>
|
||||
|
||||
<string name="capture_take_photo">Take photo</string>
|
||||
<string name="capture_stage_uploading">Uploading…</string>
|
||||
<string name="capture_stage_preprocessing">Preprocessing image…</string>
|
||||
<string name="capture_stage_ocr">Reading text (OCR)…</string>
|
||||
<string name="capture_stage_extracting">Extracting fields…</string>
|
||||
<string name="capture_stage_error">Something went wrong: %1$s</string>
|
||||
<string name="capture_retry">Retry</string>
|
||||
<string name="capture_save">Save</string>
|
||||
<string name="capture_field_petrol_station">Petrol station</string>
|
||||
<string name="capture_field_transaction_number">Transaction number</string>
|
||||
<string name="capture_field_date">Date (DD.MM.YYYY)</string>
|
||||
<string name="capture_field_time">Time (HH:MM:SS)</string>
|
||||
<string name="capture_field_price_per_litre">Price per litre</string>
|
||||
<string name="capture_field_litres">Litres</string>
|
||||
<string name="capture_field_discount">Discount</string>
|
||||
<string name="capture_field_total_price">Total price</string>
|
||||
|
||||
<string name="overview_empty">No receipts saved yet</string>
|
||||
<string name="overview_export_csv">Export CSV</string>
|
||||
<string name="overview_export_ods">Export ODS</string>
|
||||
<string name="overview_export_done">Exported to %1$s</string>
|
||||
|
||||
<string name="settings_server_url">Server URL</string>
|
||||
<string name="settings_server_url_hint">http://192.168.1.10:8080</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Fuelish" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
|
||||
<style name="Theme.Fuelish.Splash" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@color/splash_background</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.Fuelish</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Where the FileProvider (see AndroidManifest.xml) is allowed to serve
|
||||
content:// URIs from. Captured receipt photos are written to the
|
||||
cache dir by CaptureScreen before being handed to the camera intent
|
||||
and then uploaded - see ui/capture/CaptureViewModel.kt. -->
|
||||
<paths>
|
||||
<cache-path name="captures" path="captures/" />
|
||||
<!-- Exported CSV/ODS files (see export/CsvExporter.kt, OdsExporter.kt),
|
||||
shared out via an ACTION_SEND intent - see
|
||||
ui/overview/OverviewViewModel.kt. -->
|
||||
<cache-path name="exports" path="exports/" />
|
||||
</paths>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- fuelish talks to a self-hosted, LAN-only server with no TLS (see
|
||||
AGENT.md / the server plan - auth and TLS are an explicit v1
|
||||
non-goal for a personal LAN tool). Android's network security config
|
||||
has no CIDR-range matcher, only literal domains/IPs, so rather than
|
||||
enumerate private-range addresses we allow cleartext globally; the
|
||||
app never talks to anything but the server URL the user enters in
|
||||
Settings. -->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
Reference in New Issue
Block a user