diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..d18a25e --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,95 @@ +name: build + +# Builds the Android app (android/) debug APK on every push/PR, plus +# on-demand via the Gitea "Run workflow" button. Runs inside a public, +# prebuilt Android CI image (JDK + Android SDK/cmdline-tools bundled - +# fuelish's app has no native/NDK code, so it doesn't need a custom +# builder image the way a project with native components would). +on: + push: + pull_request: + workflow_dispatch: + +jobs: + build: + # Runner defaults `run:` steps to `sh`, which doesn't understand + # `set -o pipefail` used below - force bash explicitly. + defaults: + run: + shell: bash + # Must match a label your act_runner is registered with. + runs-on: ubuntu-latest + + container: + # JDK + Android SDK cmdline-tools, no platform/build-tools installed - + # the "Install SDK components" step below fetches exactly what + # compileSdk in android/app/build.gradle.kts needs. Much smaller than + # kitchen-sink alternatives (~0.7GB vs. several GB), which matters + # since every job pulls it fresh. + image: thyrlian/android-sdk:10.0 + + steps: + - uses: actions/checkout@v4 + with: + # APK naming below uses `git rev-parse --short HEAD` - harmless + # with the default shallow clone, but fetch-depth: 0 keeps this + # consistent if that ever switches to `git describe --tags`. + fetch-depth: 0 + + - name: Preflight + run: | + set -euo pipefail + command -v java >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: java not in PATH" >&2; exit 1; } + [ -n "${ANDROID_HOME:-}" ] || { echo "PREFLIGHT FAIL: ANDROID_HOME not set" >&2; exit 1; } + [ -x android/gradlew ] || { echo "PREFLIGHT FAIL: android/gradlew missing or not executable" >&2; exit 1; } + + - name: Install SDK components + run: | + set -euo pipefail + # python3-pil: android/scripts/gen_app_icons.py's Pillow dependency + # (see android/app/build.gradle.kts's generateAppIcons preBuild task) + # - not bundled in this image. + apt-get update -qq + apt-get install -y --no-install-recommends python3-pil + yes | sdkmanager --licenses >/dev/null 2>&1 || true + # Keep in sync with android/app/build.gradle.kts's compileSdk. + sdkmanager "platforms;android-36" "build-tools;36.0.0" + + - name: Build + working-directory: android + run: ./gradlew :app:assembleDebug --console=plain + + - name: Package + # Renames the debug APK with the commit it was built from, so + # multiple builds in dist/ (and on dl.ladkau.de) don't collide. + run: | + set -euo pipefail + mkdir -p dist + sha="$(git rev-parse --short HEAD)" + cp android/app/build/outputs/apk/debug/app-debug.apk "dist/fuelish-$sha.apk" + + - name: Upload build artifact + # v4 uses the newer @actions/artifact backend, which this Gitea + # instance's artifact storage doesn't support (GHESNotSupportedError) — v3 works. + uses: actions/upload-artifact@v3 + with: + name: fuelish-android + path: dist/fuelish-*.apk + + - name: Publish to dl.ladkau.de + # Uploads the APK over SFTP instead of using actions/upload-artifact + # (whose zip wrapping can't be disabled). Only runs on push so PR + # builds don't publish. + if: gitea.event_name == 'push' + run: | + set -euo pipefail + APK="$(ls dist/fuelish-*.apk)" + mkdir -p ~/.ssh + echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key + chmod 600 ~/.ssh/dl_sftp_key + sftp -i ~/.ssh/dl_sftp_key -P 2223 \ + -o StrictHostKeyChecking=accept-new \ + uploader@dl.ladkau.de <("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) +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..4039837 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -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(...); +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a40d298 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/de/ladkau/fuelish/FuelishApplication.kt b/android/app/src/main/java/de/ladkau/fuelish/FuelishApplication.kt new file mode 100644 index 0000000..b3d866a --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/FuelishApplication.kt @@ -0,0 +1,5 @@ +package de.ladkau.fuelish + +import android.app.Application + +class FuelishApplication : Application() diff --git a/android/app/src/main/java/de/ladkau/fuelish/MainActivity.kt b/android/app/src/main/java/de/ladkau/fuelish/MainActivity.kt new file mode 100644 index 0000000..a322e9e --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/MainActivity.kt @@ -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() + } + } + } +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/data/ApiClient.kt b/android/app/src/main/java/de/ladkau/fuelish/data/ApiClient.kt new file mode 100644 index 0000000..bfa388e --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/data/ApiClient.kt @@ -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) + } + } +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/data/AppConfig.kt b/android/app/src/main/java/de/ladkau/fuelish/data/AppConfig.kt new file mode 100644 index 0000000..b002845 --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/data/AppConfig.kt @@ -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 = "", +) diff --git a/android/app/src/main/java/de/ladkau/fuelish/data/ConfigRepository.kt b/android/app/src/main/java/de/ladkau/fuelish/data/ConfigRepository.kt new file mode 100644 index 0000000..35e10f4 --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/data/ConfigRepository.kt @@ -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 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 = 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 + } + } +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/data/FuelishDatabase.kt b/android/app/src/main/java/de/ladkau/fuelish/data/FuelishDatabase.kt new file mode 100644 index 0000000..6281853 --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/data/FuelishDatabase.kt @@ -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 } + } + } +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/data/JobDto.kt b/android/app/src/main/java/de/ladkau/fuelish/data/JobDto.kt new file mode 100644 index 0000000..185dc8d --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/data/JobDto.kt @@ -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, +) diff --git a/android/app/src/main/java/de/ladkau/fuelish/data/Receipt.kt b/android/app/src/main/java/de/ladkau/fuelish/data/Receipt.kt new file mode 100644 index 0000000..1c185ac --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/data/Receipt.kt @@ -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, +) diff --git a/android/app/src/main/java/de/ladkau/fuelish/data/ReceiptDao.kt b/android/app/src/main/java/de/ladkau/fuelish/data/ReceiptDao.kt new file mode 100644 index 0000000..732007e --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/data/ReceiptDao.kt @@ -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> + + @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) +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/data/ReceiptRepository.kt b/android/app/src/main/java/de/ladkau/fuelish/data/ReceiptRepository.kt new file mode 100644 index 0000000..180e90a --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/data/ReceiptRepository.kt @@ -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> = 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) +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/export/CsvExporter.kt b/android/app/src/main/java/de/ladkau/fuelish/export/CsvExporter.kt new file mode 100644 index 0000000..22d79fb --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/export/CsvExporter.kt @@ -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, 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 = + 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 + } diff --git a/android/app/src/main/java/de/ladkau/fuelish/export/OdsExporter.kt b/android/app/src/main/java/de/ladkau/fuelish/export/OdsExporter.kt new file mode 100644 index 0000000..a77665c --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/export/OdsExporter.kt @@ -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, 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 = """ + + + + + +""".trimIndent() + +private fun contentXml(receipts: List): String { + val rows = buildString { + append(tableRow(EXPORT_HEADERS)) + for (receipt in receipts) { + append(tableRow(receipt.toExportRow())) + } + } + return """ + + + + + + $rows + + + + + """.trimIndent() +} + +private fun tableRow(cells: List): String = buildString { + append("") + for (cell in cells) { + append("") + append(escapeXml(cell)) + append("") + } + append("") +} + +private fun escapeXml(text: String): String = text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) diff --git a/android/app/src/main/java/de/ladkau/fuelish/export/ReceiptRow.kt b/android/app/src/main/java/de/ladkau/fuelish/export/ReceiptRow.kt new file mode 100644 index 0000000..5e7c37c --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/export/ReceiptRow.kt @@ -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 = 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(), +) diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/FuelishApp.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/FuelishApp.kt new file mode 100644 index 0000000..a0e6080 --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/FuelishApp.kt @@ -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 + } +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureScreen.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureScreen.kt new file mode 100644 index 0000000..75f36df --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureScreen.kt @@ -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(null) } + var pendingUri by remember { mutableStateOf(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(), + ) +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureUiState.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureUiState.kt new file mode 100644 index 0000000..02e42f2 --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureUiState.kt @@ -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 +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureViewModel.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureViewModel.kt new file mode 100644 index 0000000..0d32a9f --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureViewModel.kt @@ -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.Idle) + val uiState: StateFlow = _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 + } +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/overview/OverviewScreen.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/overview/OverviewScreen.kt new file mode 100644 index 0000000..b54ee98 --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/overview/OverviewScreen.kt @@ -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)) +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/overview/OverviewViewModel.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/overview/OverviewViewModel.kt new file mode 100644 index 0000000..aacfb32 --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/overview/OverviewViewModel.kt @@ -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> = 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, java.io.OutputStream) -> Unit, onReady: (Uri) -> Unit) { + val app = getApplication() + 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) + } + } +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/settings/ConfigScreen.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/settings/ConfigScreen.kt new file mode 100644 index 0000000..18a6439 --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/settings/ConfigScreen.kt @@ -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)) + } + } +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/settings/ConfigViewModel.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/settings/ConfigViewModel.kt new file mode 100644 index 0000000..63ae510 --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/settings/ConfigViewModel.kt @@ -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 = _config.asStateFlow() + + private val _savedConfig = MutableStateFlow(AppConfig()) + + val isDirty: StateFlow = 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 + } + } +} diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/theme/Theme.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/theme/Theme.kt new file mode 100644 index 0000000..88c1e7b --- /dev/null +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/theme/Theme.kt @@ -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) +} diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..be43858 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..be43858 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..405b96c --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,8 @@ + + + + #FFFFFF + #FFFFFF + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..882625c --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,33 @@ + + + fuelish + + Scan receipt + Receipts + Settings + + Take photo + Uploading… + Preprocessing image… + Reading text (OCR)… + Extracting fields… + Something went wrong: %1$s + Retry + Save + Petrol station + Transaction number + Date (DD.MM.YYYY) + Time (HH:MM:SS) + Price per litre + Litres + Discount + Total price + + No receipts saved yet + Export CSV + Export ODS + Exported to %1$s + + Server URL + http://192.168.1.10:8080 + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..bdfd53b --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..8837e47 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..4c7947b --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..445dfbb --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.ksp) apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..a40f978 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,11 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +ksp.useKSP2=true + +# AGP 9's built-in Kotlin support rejects KSP's use of the kotlin.sourceSets +# DSL to register generated-source dirs (Room's annotation processor, via +# KSP) - this is AGP/KSP's own suggested suppression, not a workaround for +# anything we did. See https://developer.android.com/r/tools/built-in-kotlin +android.disallowKotlinSourceSets=false + diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml new file mode 100644 index 0000000..fe85691 --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -0,0 +1,56 @@ +[versions] +# Version-lookup tools (WebFetch/registry browsing) were unavailable while +# this file was written, so these are last-known-good pins rather than a +# freshly verified "latest stable" snapshot - if Gradle can't resolve one, +# bump it to whatever's current on Google's Maven / Maven Central. +# +# kotlin/ksp are pinned to 2.3.x rather than 2.2.20: androidx.room: +# room-common-jvm:2.8.5 declares a `{strictly 2.3.20}` constraint on +# kotlin-stdlib, which would otherwise silently resolve above what a +# 2.2.20 compiler/KSP understands. Keep these two in sync if Room is ever +# bumped further. +agp = "9.2.1" +kotlin = "2.3.20" +# KSP's versioning stopped mirroring Kotlin's exact patch after the 2.3.0 +# line (no "2.3.20" KSP release exists) - 2.3.12 is the latest in the same +# 2.3.x line as kotlin above. +ksp = "2.3.12" +coreKtx = "1.17.0" +lifecycleRuntimeKtx = "2.9.4" +activityCompose = "1.13.0" +composeBom = "2026.06.01" +navigationCompose = "2.9.8" +datastorePreferences = "1.2.1" +kotlinxSerializationJson = "1.11.0" +kotlinxCoroutines = "1.11.0" +room = "2.8.5" +splashscreen = "1.0.1" +okhttp = "4.12.0" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" } +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a9db115 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..a51ec4f --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/android/scripts/gen_app_icons.py b/android/scripts/gen_app_icons.py new file mode 100644 index 0000000..f7d6259 --- /dev/null +++ b/android/scripts/gen_app_icons.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Generates the Android launcher icon and splash screen art from +../../logo.png (the repo's brand mark + "fuelish" wordmark). + +Only the graphic mark (not the wordmark) works as a launcher/splash icon, +so this first isolates it: crop the upper portion of the source image +(where the mark lives) and auto-crop that to its actual non-transparent +bounding box, rather than relying on any hardcoded pixel offsets that +would break if logo.png is ever redrawn at a different resolution. + +Produces: +- mipmap-/ic_launcher_foreground.png - the mark centered, scaled + to the adaptive-icon safe zone (66% of a 108dp canvas), transparent + background - paired at runtime with @color/ic_launcher_background (see + mipmap-anydpi-v26/ic_launcher.xml, committed - a static reference, not + generated) for API 26+. +- mipmap-/ic_launcher.png / ic_launcher_round.png - the same + mark flattened onto ic_launcher_background, for API <26 devices that + can't use the adaptive-icon XML at all. +- drawable-nodpi/splash_icon.png - the mark on a transparent background, + referenced by Theme.Fuelish.Splash's windowSplashScreenAnimatedIcon + (see values/themes.xml). + +Generated output, not committed (see the repo's root .gitignore's +/android/app/src/main/res/{mipmap-*,drawable-nodpi} entries) - regenerate +by running this script directly or via Gradle's generateAppIcons task +(wired into app's preBuild, see ../app/build.gradle.kts). Requires +Pillow. +""" +import os +import sys + +from PIL import Image + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +LOGO_PATH = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "logo.png")) +RES_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "app", "src", "main", "res")) + +BACKGROUND_RGBA = (255, 255, 255, 255) # matches @color/ic_launcher_background + +# (density qualifier, adaptive-icon canvas px, legacy launcher px) +DENSITIES = [ + ("mdpi", 108, 48), + ("hdpi", 162, 72), + ("xhdpi", 216, 96), + ("xxhdpi", 324, 144), + ("xxxhdpi", 432, 192), +] + +# Adaptive icons render only the inner ~66% "safe zone" reliably across +# launchers that apply their own mask shape. +SAFE_ZONE_RATIO = 0.66 + +# Fraction of logo.png's height (from the top) that contains the graphic +# mark, before the "fuelish" wordmark begins. +MARK_REGION_HEIGHT_RATIO = 0.60 + +SPLASH_CANVAS_PX = 480 +SPLASH_ICON_RATIO = 0.75 + + +def extract_mark(logo: Image.Image) -> Image.Image: + width, height = logo.size + region = logo.crop((0, 0, width, round(height * MARK_REGION_HEIGHT_RATIO))) + bbox = region.getchannel("A").getbbox() + if bbox is None: + raise ValueError(f"{LOGO_PATH}: no non-transparent pixels found in the top " + f"{MARK_REGION_HEIGHT_RATIO:.0%} of the image") + mark = region.crop(bbox) + + # Pad to square so downstream scaling doesn't distort the aspect ratio. + side = max(mark.width, mark.height) + square = Image.new("RGBA", (side, side), (0, 0, 0, 0)) + square.paste(mark, ((side - mark.width) // 2, (side - mark.height) // 2), mark) + return square + + +def resized(image: Image.Image, target_px: int) -> Image.Image: + ratio = target_px / max(image.width, image.height) + size = (round(image.width * ratio), round(image.height * ratio)) + return image.resize(size, Image.LANCZOS) + + +def centered_on(canvas_px: int, content: Image.Image, background: tuple) -> Image.Image: + canvas = Image.new("RGBA", (canvas_px, canvas_px), background) + offset = ((canvas_px - content.width) // 2, (canvas_px - content.height) // 2) + canvas.paste(content, offset, content) + return canvas + + +def main(): + logo = Image.open(LOGO_PATH).convert("RGBA") + mark = extract_mark(logo) + + for density, canvas_px, legacy_px in DENSITIES: + mipmap_dir = os.path.join(RES_DIR, f"mipmap-{density}") + os.makedirs(mipmap_dir, exist_ok=True) + + foreground = centered_on(canvas_px, resized(mark, round(canvas_px * SAFE_ZONE_RATIO)), (0, 0, 0, 0)) + foreground.save(os.path.join(mipmap_dir, "ic_launcher_foreground.png")) + + legacy = centered_on(legacy_px, resized(mark, round(legacy_px * SAFE_ZONE_RATIO)), BACKGROUND_RGBA) + legacy.convert("RGB").save(os.path.join(mipmap_dir, "ic_launcher.png")) + legacy.convert("RGB").save(os.path.join(mipmap_dir, "ic_launcher_round.png")) + + drawable_dir = os.path.join(RES_DIR, "drawable-nodpi") + os.makedirs(drawable_dir, exist_ok=True) + splash = centered_on(SPLASH_CANVAS_PX, resized(mark, round(SPLASH_CANVAS_PX * SPLASH_ICON_RATIO)), (0, 0, 0, 0)) + splash.save(os.path.join(drawable_dir, "splash_icon.png")) + + print(f"wrote launcher icons for {len(DENSITIES)} densities + splash icon to {RES_DIR}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..077e335 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +// Auto-provisions the JDK 17 toolchain app/build.gradle.kts requests (see +// its jvmToolchain(17) call) - this host's only JDKs otherwise are system +// JDK 11 (too old for AGP 9) and Android Studio's bundled JBR 25 (newer +// than Kotlin's compiler officially targets yet). +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "fuelish-android" +include(":app") diff --git a/build-image.sh b/build-image.sh old mode 100644 new mode 100755 index 4a67757..f3b63c0 --- a/build-image.sh +++ b/build-image.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash # Builds the fuelish tools image (tooling-container/Dockerfile) locally, tagged with -# tooling-container/VERSION and :latest. Does not push - test it with the numbered -# pipeline scripts first, then publish with ./upload-image.sh. +# tooling-container/VERSION and :latest, and pulls the other image the pipeline +# needs (ollama/ollama, used by 0_run_ollama.sh) so all images are ready before +# a pipeline run. Does not push - test it with the numbered pipeline scripts +# first, then publish with ./upload-image.sh. set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")" @@ -29,6 +31,9 @@ docker build \ -t "$IMAGE:latest" \ "$ROOT/tooling-container" +echo "== Pulling ollama/ollama (used by 0_run_ollama.sh) ==" +docker pull ollama/ollama + echo "== Done ==" echo "Image: $IMAGE:$VERSION (and :latest)" echo "Test it with the numbered pipeline scripts, then publish with ./upload-image.sh" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..851f146 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +# Runs the pipeline as a long-lived HTTP API for the Android app, on top +# of the same tooling-container image the numbered host scripts use. +# `docker compose up` alone is enough: the api service waits for Ollama +# and pulls the model itself (see tooling-container/src/server.py). +# +# The numbered host scripts (0_run_ollama.sh, 1/2/3_*.sh) and +# test_batch.sh are untouched and remain the way to inspect/test the +# pipeline stage by stage; this is the additive "serve it for real" path. +# +# Usage: docker compose up -d +# API reachable at http://:8080 from any device on the LAN. +services: + ollama: + image: ollama/ollama + volumes: + - fuelish-ollama-data:/root/.ollama + restart: unless-stopped + + api: + build: + context: ./tooling-container + restart: unless-stopped + depends_on: + - ollama + environment: + OLLAMA_URL: http://ollama:11434/api/chat + OLLAMA_BASE_URL: http://ollama:11434 + OLLAMA_MODEL: qwen2.5:3b + command: ["uvicorn", "server:app", "--app-dir", "/opt/app/src", "--host", "0.0.0.0", "--port", "8000"] + ports: + - "8080:8000" + +volumes: + fuelish-ollama-data: + name: fuelish-ollama-data diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..4dd344c Binary files /dev/null and b/logo.png differ diff --git a/test_batch.sh b/test_batch.sh old mode 100644 new mode 100755 diff --git a/tooling-container/Dockerfile b/tooling-container/Dockerfile index 357e872..34a204e 100644 --- a/tooling-container/Dockerfile +++ b/tooling-container/Dockerfile @@ -11,7 +11,10 @@ RUN pip install --no-cache-dir \ pillow==11.0.0 \ pytesseract==0.3.13 \ requests==2.32.3 \ - odfpy==1.4.1 + odfpy==1.4.1 \ + fastapi==0.115.5 \ + uvicorn[standard]==0.32.1 \ + python-multipart==0.0.17 COPY src/ /opt/app/src/ RUN chmod -R a+rX /opt/app/src diff --git a/tooling-container/src/server.py b/tooling-container/src/server.py new file mode 100644 index 0000000..3cf54c1 --- /dev/null +++ b/tooling-container/src/server.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""HTTP API that serves the pipeline (preprocess -> ocr -> structure) to +the Android app. Runs the same in-process functions the numbered host +scripts drive via `docker run` per stage, but skips the per-stage +container start-up since a live server has no need for that +inspectability - the host scripts remain the way to inspect each stage +independently during pipeline development. + +Jobs are processed asynchronously so the app can show real per-stage +progress: POST /jobs returns immediately with a job id; GET /jobs/{id} +reports which of preprocessing/ocr/extracting is currently running, and +the final result once done. Job state lives in memory only - this is a +personal, LAN-only, single-instance server with no need for persistence +here (the Android app is the system of record for confirmed receipts). +""" +import os +import shutil +import tempfile +import threading +import time +import uuid + +import requests +from fastapi import FastAPI, HTTPException, UploadFile +from fastapi.responses import JSONResponse + +from extract import structure +from ocr import ocr as run_ocr +from preprocess import preprocess + +OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434") +OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:3b") + +app = FastAPI(title="fuelish") + +_jobs: dict[str, dict] = {} +_jobs_lock = threading.Lock() + + +def _set_job(job_id: str, **fields) -> None: + with _jobs_lock: + _jobs[job_id].update(fields) + + +def _ensure_model_pulled() -> None: + """Best-effort: wait for Ollama to come up (compose starts `api` and + `ollama` together, with no ordering guarantee beyond container start) + and pull OLLAMA_MODEL if it isn't already present, so a bare `docker + compose up` is enough without a manual `ollama pull` step. Runs in a + background thread - jobs submitted before this finishes will simply + fail with a clear error from Ollama.""" + tags = None + for _ in range(60): + try: + tags = requests.get(f"{OLLAMA_BASE_URL}/api/tags", timeout=5).json() + break + except requests.RequestException: + time.sleep(2) + if tags is None: + return + + have = {m["name"] for m in tags.get("models", [])} + if OLLAMA_MODEL in have or any(m.startswith(OLLAMA_MODEL + ":") for m in have): + return + try: + requests.post( + f"{OLLAMA_BASE_URL}/api/pull", + json={"name": OLLAMA_MODEL}, + timeout=1800, + ) + except requests.RequestException: + pass + + +@app.on_event("startup") +def _on_startup() -> None: + threading.Thread(target=_ensure_model_pulled, daemon=True).start() + + +@app.get("/health") +def health() -> dict: + return {"status": "ok"} + + +def _process_job(job_id: str, image_path: str, scratch_dir: str) -> None: + try: + _set_job(job_id, status="preprocessing") + final_path = preprocess(image_path, scratch_dir) + + _set_job(job_id, status="ocr") + ocr_txt_path = run_ocr(final_path, scratch_dir) + with open(ocr_txt_path, "r", encoding="utf-8") as f: + ocr_text = f.read() + + _set_job(job_id, status="extracting") + result = structure(ocr_text) + + _set_job(job_id, status="done", result=result) + except Exception as exc: # noqa: BLE001 - reported to the client, not swallowed + _set_job(job_id, status="error", error=str(exc)) + finally: + shutil.rmtree(scratch_dir, ignore_errors=True) + + +@app.post("/jobs", status_code=201) +async def create_job(image: UploadFile) -> JSONResponse: + job_id = uuid.uuid4().hex + scratch_dir = tempfile.mkdtemp(prefix=f"fuelish-{job_id}-") + image_path = os.path.join(scratch_dir, "upload" + os.path.splitext(image.filename or "")[1]) + with open(image_path, "wb") as f: + f.write(await image.read()) + + with _jobs_lock: + _jobs[job_id] = {"status": "queued", "result": None, "error": None, "_created": time.time()} + + thread = threading.Thread(target=_process_job, args=(job_id, image_path, scratch_dir), daemon=True) + thread.start() + + return JSONResponse({"job_id": job_id}, status_code=201) + + +@app.get("/jobs/{job_id}") +def get_job(job_id: str) -> dict: + with _jobs_lock: + job = _jobs.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="job not found") + return {"status": job["status"], "result": job["result"], "error": job["error"]} diff --git a/upload-image.sh b/upload-image.sh old mode 100644 new mode 100755