Add HTTP API server, Android app, and CI build/publish pipeline
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:
ml
2026-09-29 12:58:58 +02:00
parent 7cea285ec3
commit 516f5d3b7d
54 changed files with 2101 additions and 3 deletions
+79
View File
@@ -0,0 +1,79 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
}
android {
namespace = "de.ladkau.fuelish"
compileSdk = 36
defaultConfig {
applicationId = "de.ladkau.fuelish"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "0.0.0-dev"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
}
// Compiles with an actual JDK 17 toolchain (auto-provisioned - see
// settings.gradle.kts's foojay-resolver plugin) rather than whatever JDK
// launched Gradle, so it stays in sync with android.compileOptions above
// regardless of which JDK's on PATH.
kotlin {
jvmToolchain(17)
}
// Regenerates app/src/main/res/{mipmap-*,drawable-nodpi}/* from ../../logo.png
// (see scripts/gen_app_icons.py) - gitignored generated output, so a fresh
// checkout needs it produced before any resource-merge/asset-packaging task
// runs.
val generateAppIcons = tasks.register<Exec>("generateAppIcons") {
workingDir = rootDir
commandLine("python3", "scripts/gen_app_icons.py")
}
tasks.named("preBuild") {
dependsOn(generateAppIcons)
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.core)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.okhttp)
debugImplementation(libs.androidx.ui.tooling)
}
+9
View File
@@ -0,0 +1,9 @@
# kotlinx.serialization needs its generated serializer classes kept.
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclasseswithmembers class de.ladkau.fuelish.data.** {
*** Companion;
}
-keepclasseswithmembers class de.ladkau.fuelish.data.** {
kotlinx.serialization.KSerializer serializer(...);
}
+39
View File
@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
@@ -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>