diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b99244a..3bde68b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -40,6 +40,9 @@ jobs:
- name: Run unit tests
run: ./gradlew testDebugUnitTest --no-daemon
+ - name: Run Android lint
+ run: ./gradlew lintDebug --no-daemon
+
- name: Build debug APK
run: ./gradlew assembleDebug --no-daemon
@@ -50,3 +53,32 @@ jobs:
path: app/build/outputs/apk/debug/*.apk
if-no-files-found: error
retention-days: 14
+
+ instrumented-tests:
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: "21"
+ distribution: temurin
+ cache: gradle
+
+ - name: Set up Android SDK
+ uses: android-actions/setup-android@v3
+
+ - name: Grant execute permission for Gradle wrapper
+ run: chmod +x gradlew
+
+ - name: Run instrumented smoke tests
+ uses: reactivecircus/android-emulator-runner@v2
+ with:
+ api-level: 30
+ arch: x86_64
+ profile: pixel_6
+ script: ./gradlew connectedDebugAndroidTest --no-daemon
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 69403d1..fb75dcb 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -36,15 +36,23 @@ jobs:
run: chmod +x gradlew
- name: Decode release keystore
- if: ${{ secrets.ANDROID_KEYSTORE_BASE64 != '' }}
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
+ ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
+ ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
+ ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
+ APP_VERSION_CODE: ${{ github.run_number }}
run: |
+ if [ -z "$ANDROID_KEYSTORE_BASE64" ] || [ -z "$ANDROID_KEYSTORE_PASSWORD" ] || [ -z "$ANDROID_KEY_ALIAS" ] || [ -z "$ANDROID_KEY_PASSWORD" ]; then
+ echo "Release signing secrets are required for release builds." >&2
+ exit 1
+ fi
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP}/release.keystore"
echo "ANDROID_KEYSTORE_PATH=${RUNNER_TEMP}/release.keystore" >> "$GITHUB_ENV"
- echo "ANDROID_KEYSTORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" >> "$GITHUB_ENV"
- echo "ANDROID_KEY_ALIAS=${{ secrets.ANDROID_KEY_ALIAS }}" >> "$GITHUB_ENV"
- echo "ANDROID_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}" >> "$GITHUB_ENV"
+ echo "ANDROID_KEYSTORE_PASSWORD=${ANDROID_KEYSTORE_PASSWORD}" >> "$GITHUB_ENV"
+ echo "ANDROID_KEY_ALIAS=${ANDROID_KEY_ALIAS}" >> "$GITHUB_ENV"
+ echo "ANDROID_KEY_PASSWORD=${ANDROID_KEY_PASSWORD}" >> "$GITHUB_ENV"
+ echo "APP_VERSION_CODE=${APP_VERSION_CODE}" >> "$GITHUB_ENV"
- name: Build release APK
run: ./gradlew assembleRelease --no-daemon
diff --git a/README.md b/README.md
index 967ea6a..f94ab8d 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ A native Android client for [Pledger.io](https://github.com/pledger-io) — a se
| DI | Hilt (KSP) |
| Networking | Retrofit + OkHttp + Moshi |
| Images | Coil (authenticated via shared OkHttp client) |
-| Database | Room (offline cache, v3 schema) |
+| Database | Room (offline cache, v6 schema with explicit migrations) |
| Async | Coroutines + Flow |
| Navigation | Jetpack Navigation Compose |
| Background work | WorkManager |
@@ -144,7 +144,7 @@ Requires **JDK 21**.
| Workflow | Trigger | Result |
|----------|---------|--------|
-| [CI](.github/workflows/ci.yml) | Push / PR to `main` or `master` | Unit tests + debug APK artifact |
+| [CI](.github/workflows/ci.yml) | Push / PR to `main` or `master` | Unit tests + lint + instrumented smoke test + debug APK artifact |
| [Release](.github/workflows/release.yml) | **Publish** a GitHub Release, or manual run | Release APK attached to the release |
**Distributing an APK on GitHub Release**
@@ -159,7 +159,7 @@ To test the release build without publishing: **Actions → Release → Run work
Users can report problems from **Settings → About → Report a problem**. The app collects sanitized recent logs, prefills the org [bug report form](https://github.com/pledger-io/.github/issues/new?template=bug_report.yml) in the browser (summary, description, environment, logs), and the user taps **Submit** on GitHub — no app credentials required. Sign in to GitHub only if the repository requires it to open issues.
-**Optional production signing** — add these repository secrets; if omitted, CI signs the release APK with the debug keystore (fine for sideloading, not for Play Store):
+**Production signing is required for release builds** — configure these repository secrets:
| Secret | Description |
|--------|-------------|
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e752b43..d7d48ba 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -6,6 +6,25 @@ plugins {
alias(libs.plugins.ksp)
}
+val appVersionCode = System.getenv("APP_VERSION_CODE")?.toIntOrNull() ?: 1
+val releaseKeystorePath = System.getenv("ANDROID_KEYSTORE_PATH")
+val isCi = !System.getenv("CI").isNullOrBlank()
+val buildingReleaseTask = gradle.startParameter.taskNames.any { task ->
+ task.contains("Release", ignoreCase = true)
+}
+
+if (isCi && buildingReleaseTask && releaseKeystorePath.isNullOrBlank()) {
+ throw GradleException(
+ "Release signing is required in CI for release builds. Set ANDROID_KEYSTORE_PATH and signing env vars.",
+ )
+}
+
+ksp {
+ arg("room.schemaLocation", "$projectDir/schemas")
+ arg("room.incremental", "true")
+ arg("room.generateKotlin", "true")
+}
+
android {
namespace = "com.pledgerio.app"
compileSdk = libs.versions.compileSdk.get().toInt()
@@ -14,17 +33,16 @@ android {
applicationId = "com.pledgerio.app"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
- versionCode = 1
+ versionCode = appVersionCode
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
- val keystorePath = System.getenv("ANDROID_KEYSTORE_PATH")
- if (!keystorePath.isNullOrBlank()) {
+ if (!releaseKeystorePath.isNullOrBlank()) {
create("release") {
- storeFile = file(keystorePath)
+ storeFile = file(releaseKeystorePath)
storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
@@ -41,7 +59,6 @@ android {
"proguard-rules.pro"
)
signingConfig = signingConfigs.findByName("release")
- ?: signingConfigs.getByName("debug")
}
}
@@ -58,6 +75,10 @@ android {
compose = true
buildConfig = true
}
+
+ lint {
+ baseline = file("lint-baseline.xml")
+ }
}
dependencies {
diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml
new file mode 100644
index 0000000..7684f0e
--- /dev/null
+++ b/app/lint-baseline.xml
@@ -0,0 +1,2031 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/schemas/.gitkeep b/app/schemas/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/app/schemas/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/app/schemas/com.pledgerio.app.data.local.PledgerDatabase/6.json b/app/schemas/com.pledgerio.app.data.local.PledgerDatabase/6.json
new file mode 100644
index 0000000..06e01bd
--- /dev/null
+++ b/app/schemas/com.pledgerio.app.data.local.PledgerDatabase/6.json
@@ -0,0 +1,458 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 6,
+ "identityHash": "f4786ec343404eb638e79c82af2da94f",
+ "entities": [
+ {
+ "tableName": "accounts",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `currency` TEXT NOT NULL, `balance` REAL NOT NULL, `type` TEXT NOT NULL, `iconFileCode` TEXT, `iban` TEXT, `bic` TEXT, `openingBalance` REAL NOT NULL, `lastActivity` TEXT, `lastSynced` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currency",
+ "columnName": "currency",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "balance",
+ "columnName": "balance",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "iconFileCode",
+ "columnName": "iconFileCode",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "iban",
+ "columnName": "iban",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "bic",
+ "columnName": "bic",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "openingBalance",
+ "columnName": "openingBalance",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastActivity",
+ "columnName": "lastActivity",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastSynced",
+ "columnName": "lastSynced",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "transactions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `description` TEXT NOT NULL, `amount` REAL NOT NULL, `currency` TEXT NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `sourceAccountId` INTEGER, `sourceAccountName` TEXT NOT NULL, `destinationAccountId` INTEGER, `destinationAccountName` TEXT NOT NULL, `categoryName` TEXT, `budgetName` TEXT, `tags` TEXT NOT NULL, `lastSynced` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "amount",
+ "columnName": "amount",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currency",
+ "columnName": "currency",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "date",
+ "columnName": "date",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceAccountId",
+ "columnName": "sourceAccountId",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "sourceAccountName",
+ "columnName": "sourceAccountName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "destinationAccountId",
+ "columnName": "destinationAccountId",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "destinationAccountName",
+ "columnName": "destinationAccountName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "categoryName",
+ "columnName": "categoryName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "budgetName",
+ "columnName": "budgetName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSynced",
+ "columnName": "lastSynced",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "budgets",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `amount` REAL NOT NULL, `spent` REAL NOT NULL, `period` TEXT NOT NULL, `lastSynced` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "amount",
+ "columnName": "amount",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "spent",
+ "columnName": "spent",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "period",
+ "columnName": "period",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSynced",
+ "columnName": "lastSynced",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "categories",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `parentId` INTEGER, `lastSynced` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "parentId",
+ "columnName": "parentId",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastSynced",
+ "columnName": "lastSynced",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "tags",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))",
+ "fields": [
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "name"
+ ]
+ }
+ },
+ {
+ "tableName": "currencies",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`code` TEXT NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `decimalPlaces` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, PRIMARY KEY(`code`))",
+ "fields": [
+ {
+ "fieldPath": "code",
+ "columnName": "code",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "symbol",
+ "columnName": "symbol",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "decimalPlaces",
+ "columnName": "decimalPlaces",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "enabled",
+ "columnName": "enabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "code"
+ ]
+ }
+ },
+ {
+ "tableName": "contracts",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_contracts_name",
+ "unique": false,
+ "columnNames": [
+ "name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_contracts_name` ON `${TABLE_NAME}` (`name`)"
+ }
+ ]
+ },
+ {
+ "tableName": "expense_groups",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `expected` REAL NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "expected",
+ "columnName": "expected",
+ "affinity": "REAL",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_expense_groups_name",
+ "unique": false,
+ "columnNames": [
+ "name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_expense_groups_name` ON `${TABLE_NAME}` (`name`)"
+ }
+ ]
+ },
+ {
+ "tableName": "account_types",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`code` TEXT NOT NULL, PRIMARY KEY(`code`))",
+ "fields": [
+ {
+ "fieldPath": "code",
+ "columnName": "code",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "code"
+ ]
+ }
+ },
+ {
+ "tableName": "sync_metadata",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`key`))",
+ "fields": [
+ {
+ "fieldPath": "key",
+ "columnName": "key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncedAt",
+ "columnName": "lastSyncedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "key"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f4786ec343404eb638e79c82af2da94f')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/pledgerio/app/ui/MainActivitySmokeTest.kt b/app/src/androidTest/java/com/pledgerio/app/ui/MainActivitySmokeTest.kt
new file mode 100644
index 0000000..6e0181d
--- /dev/null
+++ b/app/src/androidTest/java/com/pledgerio/app/ui/MainActivitySmokeTest.kt
@@ -0,0 +1,22 @@
+package com.pledgerio.app.ui
+
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.assertIsDisplayed
+import com.pledgerio.app.MainActivity
+import com.pledgerio.app.R
+import org.junit.Rule
+import org.junit.Test
+
+class MainActivitySmokeTest {
+
+ @get:Rule
+ val composeRule = createAndroidComposeRule()
+
+ @Test
+ fun appLaunchesAndShowsServerSetup() {
+ composeRule.onNodeWithText(
+ composeRule.activity.getString(R.string.server_setup_url_label),
+ ).assertIsDisplayed()
+ }
+}
diff --git a/app/src/debug/res/xml/network_security_config.xml b/app/src/debug/res/xml/network_security_config.xml
new file mode 100644
index 0000000..d7b4192
--- /dev/null
+++ b/app/src/debug/res/xml/network_security_config.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3fd6433..1ad7cfc 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -12,7 +12,7 @@
()
- suspend fun get(month: YearMonth): Entry? = mutex.withLock { entries[month] }
+ override suspend fun get(month: YearMonth): Entry? = mutex.withLock { entries[month] }
- suspend fun put(month: YearMonth, overview: ReportsOverview, fetchedAtMillis: Long = System.currentTimeMillis()) {
+ override suspend fun put(
+ month: YearMonth,
+ overview: ReportsOverview,
+ fetchedAtMillis: Long,
+ ) {
mutex.withLock {
entries[month] = Entry(overview, fetchedAtMillis)
}
}
- suspend fun invalidate(month: YearMonth) {
+ override suspend fun invalidate(month: YearMonth) {
mutex.withLock { entries.remove(month) }
}
- suspend fun clearAll() {
+ override suspend fun clearAll() {
mutex.withLock { entries.clear() }
}
}
diff --git a/app/src/main/java/com/pledgerio/app/data/local/LocalDataCleaner.kt b/app/src/main/java/com/pledgerio/app/data/local/LocalDataCleaner.kt
index ee2647b..fb0be5f 100644
--- a/app/src/main/java/com/pledgerio/app/data/local/LocalDataCleaner.kt
+++ b/app/src/main/java/com/pledgerio/app/data/local/LocalDataCleaner.kt
@@ -5,14 +5,16 @@ import androidx.room.withTransaction
import coil.Coil
import coil.annotation.ExperimentalCoilApi
import com.pledgerio.app.data.cache.ReportsOverviewCache
+import com.pledgerio.app.di.ApplicationScope
import com.pledgerio.app.util.CurrencyProvider
import com.pledgerio.app.util.SyncWorker
import com.pledgerio.app.util.TransactionTemplateStore
import com.pledgerio.app.util.UserPreferences
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@@ -25,6 +27,8 @@ import javax.inject.Singleton
class LocalDataCleaner @Inject constructor(
private val database: PledgerDatabase,
@ApplicationContext private val context: Context,
+ @ApplicationScope private val applicationScope: CoroutineScope,
+ private val currencyProvider: Lazy,
private val userPreferences: Lazy,
private val transactionTemplateStore: Lazy,
private val reportsOverviewCache: ReportsOverviewCache,
@@ -36,7 +40,7 @@ class LocalDataCleaner @Inject constructor(
database.withTransaction {
database.clearAllTables()
}
- CurrencyProvider.getInstance()?.clearCache()
+ currencyProvider.get().clearCache()
userPreferences.get().clearSessionData()
transactionTemplateStore.get().clearAll()
reportsOverviewCache.clearAll()
@@ -50,7 +54,9 @@ class LocalDataCleaner @Inject constructor(
}
/** For non-suspend callers (e.g. [com.pledgerio.app.data.remote.api.AuthInterceptor]). */
- fun clearAllUserDataBlocking() {
- runBlocking { clearAllUserData() }
+ fun clearAllUserDataAsync() {
+ applicationScope.launch {
+ clearAllUserData()
+ }
}
}
diff --git a/app/src/main/java/com/pledgerio/app/data/local/PledgerDatabase.kt b/app/src/main/java/com/pledgerio/app/data/local/PledgerDatabase.kt
index f2c3c1f..1e2e1b9 100644
--- a/app/src/main/java/com/pledgerio/app/data/local/PledgerDatabase.kt
+++ b/app/src/main/java/com/pledgerio/app/data/local/PledgerDatabase.kt
@@ -38,7 +38,7 @@ import com.pledgerio.app.data.local.entity.TransactionEntity
SyncMetadataEntity::class,
],
version = 6,
- exportSchema = false,
+ exportSchema = true,
)
@TypeConverters(Converters::class)
abstract class PledgerDatabase : RoomDatabase() {
diff --git a/app/src/main/java/com/pledgerio/app/data/local/PledgerDatabaseMigrations.kt b/app/src/main/java/com/pledgerio/app/data/local/PledgerDatabaseMigrations.kt
new file mode 100644
index 0000000..0d76ead
--- /dev/null
+++ b/app/src/main/java/com/pledgerio/app/data/local/PledgerDatabaseMigrations.kt
@@ -0,0 +1,31 @@
+package com.pledgerio.app.data.local
+
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
+
+object PledgerDatabaseMigrations {
+
+ val MIGRATION_5_6: Migration = object : Migration(5, 6) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS `account_types` (
+ `code` TEXT NOT NULL,
+ PRIMARY KEY(`code`)
+ )
+ """.trimIndent(),
+ )
+ db.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS `sync_metadata` (
+ `key` TEXT NOT NULL,
+ `lastSyncedAt` INTEGER NOT NULL,
+ PRIMARY KEY(`key`)
+ )
+ """.trimIndent(),
+ )
+ }
+ }
+
+ val ALL: Array = arrayOf(MIGRATION_5_6)
+}
diff --git a/app/src/main/java/com/pledgerio/app/data/ocr/InvoiceTextExtractor.kt b/app/src/main/java/com/pledgerio/app/data/ocr/InvoiceTextExtractor.kt
index bc572fe..fc049dd 100644
--- a/app/src/main/java/com/pledgerio/app/data/ocr/InvoiceTextExtractor.kt
+++ b/app/src/main/java/com/pledgerio/app/data/ocr/InvoiceTextExtractor.kt
@@ -8,6 +8,7 @@ import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.text.TextRecognition
import com.google.mlkit.vision.text.latin.TextRecognizerOptions
import com.pledgerio.app.R
+import com.pledgerio.app.domain.repository.InvoiceTextReader
import com.pledgerio.app.util.Resource
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
@@ -19,9 +20,10 @@ import kotlin.coroutines.resume
@Singleton
class InvoiceTextExtractor @Inject constructor(
@ApplicationContext private val context: Context,
-) {
- suspend fun extractText(uri: Uri): Resource {
+) : InvoiceTextReader {
+ override suspend fun extractText(imageUri: String): Resource {
return try {
+ val uri = Uri.parse(imageUri)
val image = InputImage.fromFilePath(context, uri)
val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
val result = recognizer.process(image).awaitResult()
diff --git a/app/src/main/java/com/pledgerio/app/data/remote/api/AuthInterceptor.kt b/app/src/main/java/com/pledgerio/app/data/remote/api/AuthInterceptor.kt
index 79d21fb..1fe3915 100644
--- a/app/src/main/java/com/pledgerio/app/data/remote/api/AuthInterceptor.kt
+++ b/app/src/main/java/com/pledgerio/app/data/remote/api/AuthInterceptor.kt
@@ -45,7 +45,7 @@ class AuthInterceptor @Inject constructor(
return chain.proceed(retryRequest)
}
}
- localDataCleaner.clearAllUserDataBlocking()
+ localDataCleaner.clearAllUserDataAsync()
sessionManager.clearAuthTokens()
}
diff --git a/app/src/main/java/com/pledgerio/app/data/repository/TransactionRepositoryImpl.kt b/app/src/main/java/com/pledgerio/app/data/repository/TransactionRepositoryImpl.kt
index f057f54..38a1f91 100644
--- a/app/src/main/java/com/pledgerio/app/data/repository/TransactionRepositoryImpl.kt
+++ b/app/src/main/java/com/pledgerio/app/data/repository/TransactionRepositoryImpl.kt
@@ -85,12 +85,29 @@ class TransactionRepositoryImpl @Inject constructor(
Resource.Error("Failed to fetch transactions: ${response.code()}")
}
} catch (e: Exception) {
+ val hasIdBasedFilters = filters.categoryId != null ||
+ filters.expenseId != null ||
+ filters.contractId != null
+ if (hasIdBasedFilters) {
+ return Resource.Error(
+ "Filtered offline search is not available yet. Reconnect to apply category/expense/contract filters.",
+ )
+ }
if (page == 0) {
val cached = transactionDao.getAllOnce()
+ .map { it.toDomain() }
+ .filter { tx ->
+ !tx.date.isBefore(startDate) && !tx.date.isAfter(endDate) &&
+ (type == null || tx.type == type) &&
+ (
+ filters.description.isNullOrBlank() ||
+ tx.description.contains(filters.description, ignoreCase = true)
+ )
+ }
if (cached.isNotEmpty()) {
return Resource.Success(
PagedResult(
- items = cached.map { it.toDomain() },
+ items = cached,
totalRecords = cached.size.toLong(),
totalPages = 1,
pageSize = cached.size,
diff --git a/app/src/main/java/com/pledgerio/app/di/DatabaseModule.kt b/app/src/main/java/com/pledgerio/app/di/DatabaseModule.kt
index 0dc6b3b..9841a7f 100644
--- a/app/src/main/java/com/pledgerio/app/di/DatabaseModule.kt
+++ b/app/src/main/java/com/pledgerio/app/di/DatabaseModule.kt
@@ -3,6 +3,7 @@ package com.pledgerio.app.di
import android.content.Context
import androidx.room.Room
import com.pledgerio.app.data.local.PledgerDatabase
+import com.pledgerio.app.data.local.PledgerDatabaseMigrations
import com.pledgerio.app.data.local.dao.AccountDao
import com.pledgerio.app.data.local.dao.AccountTypeDao
import com.pledgerio.app.data.local.dao.BudgetDao
@@ -32,7 +33,7 @@ object DatabaseModule {
PledgerDatabase::class.java,
"pledger_database"
)
- .fallbackToDestructiveMigration()
+ .addMigrations(*PledgerDatabaseMigrations.ALL)
.build()
@Provides
diff --git a/app/src/main/java/com/pledgerio/app/di/NetworkModule.kt b/app/src/main/java/com/pledgerio/app/di/NetworkModule.kt
index 7e2b0bf..cce18b5 100644
--- a/app/src/main/java/com/pledgerio/app/di/NetworkModule.kt
+++ b/app/src/main/java/com/pledgerio/app/di/NetworkModule.kt
@@ -2,6 +2,7 @@ package com.pledgerio.app.di
import android.content.Context
import coil.ImageLoader
+import com.pledgerio.app.BuildConfig
import com.pledgerio.app.data.remote.api.AuthInterceptor
import com.pledgerio.app.data.remote.api.DynamicBaseUrlInterceptor
import com.pledgerio.app.data.remote.api.IssueLogInterceptor
@@ -59,7 +60,11 @@ object NetworkModule {
.addInterceptor(authInterceptor)
.addInterceptor(issueLogInterceptor)
.addInterceptor(HttpLoggingInterceptor().apply {
- level = HttpLoggingInterceptor.Level.BODY
+ level = if (BuildConfig.DEBUG) {
+ HttpLoggingInterceptor.Level.BODY
+ } else {
+ HttpLoggingInterceptor.Level.NONE
+ }
})
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
diff --git a/app/src/main/java/com/pledgerio/app/di/RepositoryModule.kt b/app/src/main/java/com/pledgerio/app/di/RepositoryModule.kt
index 2173d6c..b968838 100644
--- a/app/src/main/java/com/pledgerio/app/di/RepositoryModule.kt
+++ b/app/src/main/java/com/pledgerio/app/di/RepositoryModule.kt
@@ -10,6 +10,8 @@ import com.pledgerio.app.data.repository.IssueReportRepositoryImpl
import com.pledgerio.app.data.repository.ReportRepositoryImpl
import com.pledgerio.app.data.repository.TagRepositoryImpl
import com.pledgerio.app.data.repository.TransactionRepositoryImpl
+import com.pledgerio.app.data.cache.ReportsOverviewCache
+import com.pledgerio.app.data.ocr.InvoiceTextExtractor
import com.pledgerio.app.domain.repository.AccountRepository
import com.pledgerio.app.domain.repository.AuthRepository
import com.pledgerio.app.domain.repository.BudgetRepository
@@ -17,7 +19,9 @@ import com.pledgerio.app.domain.repository.CategoryRepository
import com.pledgerio.app.domain.repository.ContractRepository
import com.pledgerio.app.domain.repository.CurrencyRepository
import com.pledgerio.app.domain.repository.IssueReportRepository
+import com.pledgerio.app.domain.repository.InvoiceTextReader
import com.pledgerio.app.domain.repository.ReportRepository
+import com.pledgerio.app.domain.repository.ReportsOverviewStore
import com.pledgerio.app.domain.repository.TagRepository
import com.pledgerio.app.domain.repository.TransactionRepository
import dagger.Binds
@@ -69,4 +73,12 @@ abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindReportRepository(impl: ReportRepositoryImpl): ReportRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindReportsOverviewStore(impl: ReportsOverviewCache): ReportsOverviewStore
+
+ @Binds
+ @Singleton
+ abstract fun bindInvoiceTextReader(impl: InvoiceTextExtractor): InvoiceTextReader
}
diff --git a/app/src/main/java/com/pledgerio/app/domain/common/Resource.kt b/app/src/main/java/com/pledgerio/app/domain/common/Resource.kt
new file mode 100644
index 0000000..759481c
--- /dev/null
+++ b/app/src/main/java/com/pledgerio/app/domain/common/Resource.kt
@@ -0,0 +1,3 @@
+package com.pledgerio.app.domain.common
+
+typealias Resource = com.pledgerio.app.util.Resource
diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/AccountRepository.kt b/app/src/main/java/com/pledgerio/app/domain/repository/AccountRepository.kt
index 1b8658f..3323ec2 100644
--- a/app/src/main/java/com/pledgerio/app/domain/repository/AccountRepository.kt
+++ b/app/src/main/java/com/pledgerio/app/domain/repository/AccountRepository.kt
@@ -3,7 +3,7 @@ package com.pledgerio.app.domain.repository
import com.pledgerio.app.domain.model.Account
import com.pledgerio.app.domain.model.AccountTypeOption
import com.pledgerio.app.domain.model.PagedAccounts
-import com.pledgerio.app.util.Resource
+import com.pledgerio.app.domain.common.Resource
import kotlinx.coroutines.flow.Flow
interface AccountRepository {
diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/AuthRepository.kt b/app/src/main/java/com/pledgerio/app/domain/repository/AuthRepository.kt
index 3c3177c..cbc49da 100644
--- a/app/src/main/java/com/pledgerio/app/domain/repository/AuthRepository.kt
+++ b/app/src/main/java/com/pledgerio/app/domain/repository/AuthRepository.kt
@@ -1,6 +1,6 @@
package com.pledgerio.app.domain.repository
-import com.pledgerio.app.util.Resource
+import com.pledgerio.app.domain.common.Resource
interface AuthRepository {
suspend fun login(username: String, password: String): Resource
diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/BudgetRepository.kt b/app/src/main/java/com/pledgerio/app/domain/repository/BudgetRepository.kt
index bf54c79..054cf16 100644
--- a/app/src/main/java/com/pledgerio/app/domain/repository/BudgetRepository.kt
+++ b/app/src/main/java/com/pledgerio/app/domain/repository/BudgetRepository.kt
@@ -3,7 +3,7 @@ package com.pledgerio.app.domain.repository
import com.pledgerio.app.domain.model.Budget
import com.pledgerio.app.domain.model.BudgetExpense
import com.pledgerio.app.domain.model.BudgetListState
-import com.pledgerio.app.util.Resource
+import com.pledgerio.app.domain.common.Resource
import kotlinx.coroutines.flow.Flow
interface BudgetRepository {
diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/CategoryRepository.kt b/app/src/main/java/com/pledgerio/app/domain/repository/CategoryRepository.kt
index c3fcd23..6df99c1 100644
--- a/app/src/main/java/com/pledgerio/app/domain/repository/CategoryRepository.kt
+++ b/app/src/main/java/com/pledgerio/app/domain/repository/CategoryRepository.kt
@@ -1,7 +1,7 @@
package com.pledgerio.app.domain.repository
import com.pledgerio.app.domain.model.Category
-import com.pledgerio.app.util.Resource
+import com.pledgerio.app.domain.common.Resource
import kotlinx.coroutines.flow.Flow
interface CategoryRepository {
diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/ContractRepository.kt b/app/src/main/java/com/pledgerio/app/domain/repository/ContractRepository.kt
index 8647d07..0d4950c 100644
--- a/app/src/main/java/com/pledgerio/app/domain/repository/ContractRepository.kt
+++ b/app/src/main/java/com/pledgerio/app/domain/repository/ContractRepository.kt
@@ -1,7 +1,7 @@
package com.pledgerio.app.domain.repository
import com.pledgerio.app.domain.model.Contract
-import com.pledgerio.app.util.Resource
+import com.pledgerio.app.domain.common.Resource
import kotlinx.coroutines.flow.Flow
interface ContractRepository {
diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/InvoiceTextReader.kt b/app/src/main/java/com/pledgerio/app/domain/repository/InvoiceTextReader.kt
new file mode 100644
index 0000000..071a860
--- /dev/null
+++ b/app/src/main/java/com/pledgerio/app/domain/repository/InvoiceTextReader.kt
@@ -0,0 +1,7 @@
+package com.pledgerio.app.domain.repository
+
+import com.pledgerio.app.domain.common.Resource
+
+interface InvoiceTextReader {
+ suspend fun extractText(imageUri: String): Resource
+}
diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/IssueReportRepository.kt b/app/src/main/java/com/pledgerio/app/domain/repository/IssueReportRepository.kt
index 20849b0..056658d 100644
--- a/app/src/main/java/com/pledgerio/app/domain/repository/IssueReportRepository.kt
+++ b/app/src/main/java/com/pledgerio/app/domain/repository/IssueReportRepository.kt
@@ -1,7 +1,7 @@
package com.pledgerio.app.domain.repository
import com.pledgerio.app.domain.model.IssueReportResult
-import com.pledgerio.app.util.Resource
+import com.pledgerio.app.domain.common.Resource
interface IssueReportRepository {
suspend fun submitBugReport(title: String, description: String): Resource
diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/ReportRepository.kt b/app/src/main/java/com/pledgerio/app/domain/repository/ReportRepository.kt
index 04536c4..d1abdf1 100644
--- a/app/src/main/java/com/pledgerio/app/domain/repository/ReportRepository.kt
+++ b/app/src/main/java/com/pledgerio/app/domain/repository/ReportRepository.kt
@@ -4,7 +4,7 @@ import com.pledgerio.app.domain.model.BudgetPerformanceItem
import com.pledgerio.app.domain.model.DatedAmount
import com.pledgerio.app.domain.model.IncomeExpenseSummary
import com.pledgerio.app.domain.model.PartitionAmount
-import com.pledgerio.app.util.Resource
+import com.pledgerio.app.domain.common.Resource
import java.time.YearMonth
interface ReportRepository {
diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/ReportsOverviewStore.kt b/app/src/main/java/com/pledgerio/app/domain/repository/ReportsOverviewStore.kt
new file mode 100644
index 0000000..6ef4a75
--- /dev/null
+++ b/app/src/main/java/com/pledgerio/app/domain/repository/ReportsOverviewStore.kt
@@ -0,0 +1,30 @@
+package com.pledgerio.app.domain.repository
+
+import com.pledgerio.app.domain.model.ReportsOverview
+import java.time.YearMonth
+
+interface ReportsOverviewStore {
+
+ interface Entry {
+ val overview: ReportsOverview
+ val fetchedAtMillis: Long
+
+ fun isFresh(
+ month: YearMonth,
+ now: YearMonth = YearMonth.now(),
+ nowMillis: Long = System.currentTimeMillis(),
+ ): Boolean
+ }
+
+ suspend fun get(month: YearMonth): Entry?
+
+ suspend fun put(
+ month: YearMonth,
+ overview: ReportsOverview,
+ fetchedAtMillis: Long = System.currentTimeMillis(),
+ )
+
+ suspend fun invalidate(month: YearMonth)
+
+ suspend fun clearAll()
+}
diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/TagRepository.kt b/app/src/main/java/com/pledgerio/app/domain/repository/TagRepository.kt
index 3a3cf38..4189185 100644
--- a/app/src/main/java/com/pledgerio/app/domain/repository/TagRepository.kt
+++ b/app/src/main/java/com/pledgerio/app/domain/repository/TagRepository.kt
@@ -1,7 +1,7 @@
package com.pledgerio.app.domain.repository
import com.pledgerio.app.domain.model.Tag
-import com.pledgerio.app.util.Resource
+import com.pledgerio.app.domain.common.Resource
import kotlinx.coroutines.flow.Flow
interface TagRepository {
diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/TransactionRepository.kt b/app/src/main/java/com/pledgerio/app/domain/repository/TransactionRepository.kt
index 2330b58..655641f 100644
--- a/app/src/main/java/com/pledgerio/app/domain/repository/TransactionRepository.kt
+++ b/app/src/main/java/com/pledgerio/app/domain/repository/TransactionRepository.kt
@@ -6,7 +6,7 @@ import com.pledgerio.app.domain.model.TransactionExtractionDraft
import com.pledgerio.app.domain.model.TransactionFilters
import com.pledgerio.app.domain.model.TransactionSplit
import com.pledgerio.app.domain.model.TransactionType
-import com.pledgerio.app.util.Resource
+import com.pledgerio.app.domain.common.Resource
import kotlinx.coroutines.flow.Flow
import java.time.LocalDate
diff --git a/app/src/main/java/com/pledgerio/app/domain/usecase/CreateInitialBudgetUseCase.kt b/app/src/main/java/com/pledgerio/app/domain/usecase/CreateInitialBudgetUseCase.kt
index 5d512a6..0034e0c 100644
--- a/app/src/main/java/com/pledgerio/app/domain/usecase/CreateInitialBudgetUseCase.kt
+++ b/app/src/main/java/com/pledgerio/app/domain/usecase/CreateInitialBudgetUseCase.kt
@@ -1,7 +1,7 @@
package com.pledgerio.app.domain.usecase
import com.pledgerio.app.domain.repository.BudgetRepository
-import com.pledgerio.app.util.Resource
+import com.pledgerio.app.domain.common.Resource
import javax.inject.Inject
class CreateInitialBudgetUseCase @Inject constructor(
diff --git a/app/src/main/java/com/pledgerio/app/domain/usecase/GetBudgetsUseCase.kt b/app/src/main/java/com/pledgerio/app/domain/usecase/GetBudgetsUseCase.kt
index 1eef7b7..e3a0271 100644
--- a/app/src/main/java/com/pledgerio/app/domain/usecase/GetBudgetsUseCase.kt
+++ b/app/src/main/java/com/pledgerio/app/domain/usecase/GetBudgetsUseCase.kt
@@ -2,7 +2,7 @@ package com.pledgerio.app.domain.usecase
import com.pledgerio.app.domain.model.BudgetListState
import com.pledgerio.app.domain.repository.BudgetRepository
-import com.pledgerio.app.util.Resource
+import com.pledgerio.app.domain.common.Resource
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
diff --git a/app/src/main/java/com/pledgerio/app/domain/usecase/GetTransactionsUseCase.kt b/app/src/main/java/com/pledgerio/app/domain/usecase/GetTransactionsUseCase.kt
index 0f330db..1ee08bf 100644
--- a/app/src/main/java/com/pledgerio/app/domain/usecase/GetTransactionsUseCase.kt
+++ b/app/src/main/java/com/pledgerio/app/domain/usecase/GetTransactionsUseCase.kt
@@ -5,7 +5,7 @@ import com.pledgerio.app.domain.model.TransactionFilters
import com.pledgerio.app.domain.model.TransactionType
import com.pledgerio.app.domain.repository.PagedResult
import com.pledgerio.app.domain.repository.TransactionRepository
-import com.pledgerio.app.util.Resource
+import com.pledgerio.app.domain.common.Resource
import java.time.LocalDate
import javax.inject.Inject
diff --git a/app/src/main/java/com/pledgerio/app/domain/usecase/ProcessInvoiceScanUseCase.kt b/app/src/main/java/com/pledgerio/app/domain/usecase/ProcessInvoiceScanUseCase.kt
new file mode 100644
index 0000000..f803974
--- /dev/null
+++ b/app/src/main/java/com/pledgerio/app/domain/usecase/ProcessInvoiceScanUseCase.kt
@@ -0,0 +1,25 @@
+package com.pledgerio.app.domain.usecase
+
+import com.pledgerio.app.domain.model.TransactionExtractionDraft
+import com.pledgerio.app.domain.repository.InvoiceTextReader
+import com.pledgerio.app.domain.repository.TransactionRepository
+import com.pledgerio.app.util.Resource
+import javax.inject.Inject
+
+class ProcessInvoiceScanUseCase @Inject constructor(
+ private val invoiceTextReader: InvoiceTextReader,
+ private val transactionRepository: TransactionRepository,
+) {
+ suspend fun extractTextFromImage(imageUri: String): Resource =
+ invoiceTextReader.extractText(imageUri)
+
+ suspend fun extractDraftFromText(text: String): Resource {
+ val trimmed = text.trim()
+ if (trimmed.isBlank()) return Resource.Error("No text found in document")
+ return when (val result = transactionRepository.extractTransactionFromText(trimmed)) {
+ is Resource.Success -> Resource.Success(result.data.copy(rawText = trimmed))
+ is Resource.Error -> result
+ is Resource.Loading -> result
+ }
+ }
+}
diff --git a/app/src/main/java/com/pledgerio/app/domain/usecase/SaveBudgetExpenseUseCase.kt b/app/src/main/java/com/pledgerio/app/domain/usecase/SaveBudgetExpenseUseCase.kt
index 043d442..0acb847 100644
--- a/app/src/main/java/com/pledgerio/app/domain/usecase/SaveBudgetExpenseUseCase.kt
+++ b/app/src/main/java/com/pledgerio/app/domain/usecase/SaveBudgetExpenseUseCase.kt
@@ -2,7 +2,7 @@ package com.pledgerio.app.domain.usecase
import com.pledgerio.app.domain.model.BudgetListState
import com.pledgerio.app.domain.repository.BudgetRepository
-import com.pledgerio.app.util.Resource
+import com.pledgerio.app.domain.common.Resource
import javax.inject.Inject
class SaveBudgetExpenseUseCase @Inject constructor(
diff --git a/app/src/main/java/com/pledgerio/app/ui/budgets/BudgetsViewModel.kt b/app/src/main/java/com/pledgerio/app/ui/budgets/BudgetsViewModel.kt
index 4f79013..bc7e4c8 100644
--- a/app/src/main/java/com/pledgerio/app/ui/budgets/BudgetsViewModel.kt
+++ b/app/src/main/java/com/pledgerio/app/ui/budgets/BudgetsViewModel.kt
@@ -4,15 +4,15 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pledgerio.app.domain.model.Budget
+import com.pledgerio.app.domain.model.BudgetListState
import com.pledgerio.app.domain.usecase.CreateInitialBudgetUseCase
-import com.pledgerio.app.domain.repository.BudgetRepository
+import com.pledgerio.app.domain.usecase.GetBudgetsUseCase
import com.pledgerio.app.domain.usecase.SaveBudgetExpenseUseCase
import com.pledgerio.app.util.Resource
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import com.pledgerio.app.domain.model.BudgetListState
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -53,7 +53,7 @@ data class BudgetsUiState(
@HiltViewModel
class BudgetsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
- private val budgetRepository: BudgetRepository,
+ private val getBudgetsUseCase: GetBudgetsUseCase,
private val createInitialBudgetUseCase: CreateInitialBudgetUseCase,
private val saveBudgetExpenseUseCase: SaveBudgetExpenseUseCase,
) : ViewModel() {
@@ -248,7 +248,7 @@ class BudgetsViewModel @Inject constructor(
val month = _uiState.value.currentMonth
loadJob?.cancel()
loadJob = viewModelScope.launch {
- budgetRepository.getBudgets(month.year, month.monthValue).collect { result ->
+ getBudgetsUseCase(month.year, month.monthValue).collect { result ->
when (result) {
is Resource.Loading -> _uiState.update { it.copy(isLoading = true) }
is Resource.Success -> {
diff --git a/app/src/main/java/com/pledgerio/app/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/pledgerio/app/ui/dashboard/DashboardViewModel.kt
index 89fd0f9..3cb368c 100644
--- a/app/src/main/java/com/pledgerio/app/ui/dashboard/DashboardViewModel.kt
+++ b/app/src/main/java/com/pledgerio/app/ui/dashboard/DashboardViewModel.kt
@@ -5,12 +5,12 @@ import androidx.lifecycle.viewModelScope
import com.pledgerio.app.domain.model.Account
import com.pledgerio.app.domain.model.FinanceExperienceMode
import com.pledgerio.app.domain.model.Transaction
-import com.pledgerio.app.domain.repository.AccountRepository
import com.pledgerio.app.domain.repository.CurrencyRepository
-import com.pledgerio.app.domain.repository.TransactionRepository
+import com.pledgerio.app.domain.usecase.GetDashboardDataUseCase
import com.pledgerio.app.util.Resource
import com.pledgerio.app.util.UserPreferences
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -34,14 +34,14 @@ data class DashboardUiState(
@HiltViewModel
class DashboardViewModel @Inject constructor(
- private val accountRepository: AccountRepository,
- private val transactionRepository: TransactionRepository,
+ private val getDashboardDataUseCase: GetDashboardDataUseCase,
private val currencyRepository: CurrencyRepository,
private val userPreferences: UserPreferences,
) : ViewModel() {
private val _uiState = MutableStateFlow(DashboardUiState())
val uiState: StateFlow = _uiState.asStateFlow()
+ private var dashboardJob: Job? = null
init {
viewModelScope.launch { currencyRepository.sync() }
@@ -65,32 +65,27 @@ class DashboardViewModel @Inject constructor(
/** Reload recent transactions when returning to the dashboard (e.g. after creating one). */
fun refreshRecentTransactions() {
- viewModelScope.launch {
- transactionRepository.getRecentTransactions(5).collect { result ->
- when (result) {
- is Resource.Success -> applyRecentTransactions(result.data)
- else -> Unit
- }
- }
- }
+ loadDashboard()
}
private fun loadDashboard() {
- viewModelScope.launch {
- accountRepository.getAccounts().collect { result ->
+ dashboardJob?.cancel()
+ dashboardJob = viewModelScope.launch {
+ getDashboardDataUseCase().collect { result ->
when (result) {
is Resource.Loading -> {
_uiState.update { it.copy(isLoading = true) }
}
is Resource.Success -> {
- val accounts = result.data
- val netWorth = accounts.sumOf { it.balance }
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
- accounts = accounts,
- netWorth = netWorth,
+ accounts = result.data.accounts,
+ recentTransactions = result.data.recentTransactions,
+ netWorth = result.data.netWorth,
+ monthlyIncome = result.data.monthlyIncome,
+ monthlyExpense = result.data.monthlyExpense,
currency = userPreferences.displayCurrencyCode.value,
error = null,
lastUpdatedAtMillis = System.currentTimeMillis(),
@@ -109,30 +104,5 @@ class DashboardViewModel @Inject constructor(
}
}
}
-
- viewModelScope.launch {
- transactionRepository.getRecentTransactions(5).collect { result ->
- when (result) {
- is Resource.Success -> applyRecentTransactions(result.data)
- else -> Unit
- }
- }
- }
- }
-
- private fun applyRecentTransactions(transactions: List) {
- val income = transactions
- .filter { it.type == com.pledgerio.app.domain.model.TransactionType.DEBIT }
- .sumOf { it.amount }
- val expense = transactions
- .filter { it.type == com.pledgerio.app.domain.model.TransactionType.CREDIT }
- .sumOf { it.amount }
- _uiState.update {
- it.copy(
- recentTransactions = transactions,
- monthlyIncome = income,
- monthlyExpense = expense,
- )
- }
}
}
diff --git a/app/src/main/java/com/pledgerio/app/ui/onboarding/LoginViewModel.kt b/app/src/main/java/com/pledgerio/app/ui/onboarding/LoginViewModel.kt
index 41f7dbe..a84c2c6 100644
--- a/app/src/main/java/com/pledgerio/app/ui/onboarding/LoginViewModel.kt
+++ b/app/src/main/java/com/pledgerio/app/ui/onboarding/LoginViewModel.kt
@@ -2,8 +2,8 @@ package com.pledgerio.app.ui.onboarding
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.pledgerio.app.domain.repository.AuthRepository
import com.pledgerio.app.domain.repository.CurrencyRepository
+import com.pledgerio.app.domain.usecase.LoginUseCase
import com.pledgerio.app.util.Resource
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -22,7 +22,7 @@ data class LoginUiState(
@HiltViewModel
class LoginViewModel @Inject constructor(
- private val authRepository: AuthRepository,
+ private val loginUseCase: LoginUseCase,
private val currencyRepository: CurrencyRepository,
) : ViewModel() {
@@ -47,7 +47,7 @@ class LoginViewModel @Inject constructor(
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
- when (val result = authRepository.login(state.username, state.password)) {
+ when (val result = loginUseCase(state.username, state.password)) {
is Resource.Success -> {
currencyRepository.sync()
_uiState.update { it.copy(isLoading = false) }
diff --git a/app/src/main/java/com/pledgerio/app/ui/reports/ReportsViewModel.kt b/app/src/main/java/com/pledgerio/app/ui/reports/ReportsViewModel.kt
index db963ec..274b963 100644
--- a/app/src/main/java/com/pledgerio/app/ui/reports/ReportsViewModel.kt
+++ b/app/src/main/java/com/pledgerio/app/ui/reports/ReportsViewModel.kt
@@ -2,13 +2,13 @@ package com.pledgerio.app.ui.reports
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.pledgerio.app.data.cache.ReportsOverviewCache
import com.pledgerio.app.domain.model.BudgetPerformanceItem
import com.pledgerio.app.domain.model.DatedAmount
import com.pledgerio.app.domain.model.IncomeExpenseSummary
import com.pledgerio.app.domain.model.PartitionAmount
import com.pledgerio.app.domain.model.ReportsOverview
import com.pledgerio.app.domain.repository.ReportRepository
+import com.pledgerio.app.domain.repository.ReportsOverviewStore
import com.pledgerio.app.util.Resource
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
@@ -45,7 +45,7 @@ data class ReportsUiState(
@HiltViewModel
class ReportsViewModel @Inject constructor(
private val reportRepository: ReportRepository,
- private val overviewCache: ReportsOverviewCache,
+ private val overviewCache: ReportsOverviewStore,
) : ViewModel() {
private val _uiState = MutableStateFlow(ReportsUiState())
diff --git a/app/src/main/java/com/pledgerio/app/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/pledgerio/app/ui/settings/SettingsViewModel.kt
index 1386777..e317fab 100644
--- a/app/src/main/java/com/pledgerio/app/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/pledgerio/app/ui/settings/SettingsViewModel.kt
@@ -12,7 +12,6 @@ import com.pledgerio.app.domain.repository.CurrencyRepository
import com.pledgerio.app.util.BiometricAuthenticator
import com.pledgerio.app.util.BiometricAvailability
import com.pledgerio.app.util.BiometricLockManager
-import com.pledgerio.app.util.CurrencyProvider
import com.pledgerio.app.util.SessionManager
import com.pledgerio.app.util.UserPreferences
import androidx.fragment.app.FragmentActivity
@@ -214,7 +213,6 @@ class SettingsViewModel @Inject constructor(
currencies: List = _uiState.value.currencies,
): String {
val currency = currencies.find { it.code == code }
- ?: CurrencyProvider.getInstance()?.get(code)
return if (currency != null) {
"${currency.code} — ${currency.name} (${currency.symbol})"
} else {
diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionDetailScreen.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionDetailScreen.kt
index ef4922e..1f54f4e 100644
--- a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionDetailScreen.kt
+++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionDetailScreen.kt
@@ -31,7 +31,6 @@ import com.pledgerio.app.ui.transactions.detail.TransactionDetailFlowCard
import com.pledgerio.app.ui.transactions.detail.TransactionDetailHeroCard
import com.pledgerio.app.ui.transactions.detail.TransactionDetailSplitCard
import com.pledgerio.app.ui.transactions.detail.TransactionDetailTagsCard
-import com.pledgerio.app.util.CurrencyProvider
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -72,9 +71,6 @@ fun TransactionDetailScreen(
modifier = Modifier.padding(paddingValues),
)
transaction != null -> {
- val currencyInfo = CurrencyProvider.getInstance()?.get(transaction.currency)
- val currencyDisplay = currencyInfo?.let { "${it.name} (${it.symbol})" }
- ?: transaction.currency
val hasClassification = transaction.budgetName != null ||
transaction.categoryName != null ||
transaction.contractName != null
diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormAccountPreservation.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormAccountPreservation.kt
new file mode 100644
index 0000000..9a8f279
--- /dev/null
+++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormAccountPreservation.kt
@@ -0,0 +1,39 @@
+package com.pledgerio.app.ui.transactions
+
+import com.pledgerio.app.domain.model.Account
+import com.pledgerio.app.domain.model.FilterOption
+
+internal data class PreservedAccountSelection(
+ val accountId: Long?,
+ val selected: FilterOption?,
+ val query: String,
+)
+
+internal fun preserveAccountSelection(
+ accountId: Long?,
+ selected: FilterOption?,
+ query: String,
+ ownedAccounts: List,
+ oldKind: AccountInputKind,
+ newKind: AccountInputKind,
+): PreservedAccountSelection {
+ if (oldKind != newKind) return PreservedAccountSelection(null, null, "")
+ return when (newKind) {
+ AccountInputKind.OWNED_DROPDOWN -> {
+ if (accountId != null && ownedAccounts.any { it.id == accountId }) {
+ PreservedAccountSelection(accountId, null, "")
+ } else {
+ PreservedAccountSelection(null, null, "")
+ }
+ }
+ AccountInputKind.CREDITOR_AUTOCOMPLETE,
+ AccountInputKind.DEBTOR_AUTOCOMPLETE,
+ -> {
+ if (selected != null && accountId != null) {
+ PreservedAccountSelection(accountId, selected, query)
+ } else {
+ PreservedAccountSelection(null, null, "")
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormAutoClassify.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormAutoClassify.kt
new file mode 100644
index 0000000..0dc7139
--- /dev/null
+++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormAutoClassify.kt
@@ -0,0 +1,110 @@
+package com.pledgerio.app.ui.transactions
+
+import com.pledgerio.app.domain.model.FilterOption
+import com.pledgerio.app.ui.transactions.form.AutoClassifyStatus
+import com.pledgerio.app.ui.transactions.form.ClassifyPart
+
+internal data class AutoClassifyApplyResult(
+ val updatedState: TransactionFormUiState,
+ val unresolvedCategoryQuery: String?,
+ val unresolvedExpenseQuery: String?,
+)
+
+internal fun applyAutoClassifySuggestion(
+ current: TransactionFormUiState,
+ suggestedCategoryRaw: String?,
+ suggestedExpenseRaw: String?,
+ suggestedTagsRaw: List,
+ categoryOption: FilterOption?,
+ expenseOption: FilterOption?,
+): AutoClassifyApplyResult {
+ val suggestedCategory = suggestedCategoryRaw?.trim().orEmpty()
+ val suggestedExpense = suggestedExpenseRaw?.trim().orEmpty()
+ val suggestedTags = suggestedTagsRaw
+ .map { it.trim() }
+ .filter { it.isNotBlank() }
+
+ val status = resolveAutoClassifyStatus(
+ suggestedCategory = suggestedCategory,
+ suggestedExpense = suggestedExpense,
+ suggestedTags = suggestedTags,
+ categoryOption = categoryOption,
+ expenseOption = expenseOption,
+ )
+
+ val mergedTags = if (suggestedTags.isNotEmpty()) {
+ (current.tags + suggestedTags).distinctBy { it.lowercase() }
+ } else {
+ current.tags
+ }
+ val shouldExpand = suggestedCategory.isNotBlank() ||
+ suggestedExpense.isNotBlank() ||
+ suggestedTags.isNotEmpty()
+
+ return AutoClassifyApplyResult(
+ updatedState = current.copy(
+ isAutoClassifying = false,
+ autoClassifyStatus = status,
+ categorySelected = when {
+ categoryOption != null -> categoryOption
+ suggestedCategory.isNotBlank() -> null
+ else -> current.categorySelected
+ },
+ categoryQuery = when {
+ categoryOption != null -> categoryOption.label
+ suggestedCategory.isNotBlank() -> suggestedCategory
+ else -> current.categoryQuery
+ },
+ categorySuggestions = emptyList(),
+ expenseSelected = when {
+ expenseOption != null -> expenseOption
+ suggestedExpense.isNotBlank() -> null
+ else -> current.expenseSelected
+ },
+ expenseQuery = when {
+ expenseOption != null -> expenseOption.label
+ suggestedExpense.isNotBlank() -> suggestedExpense
+ else -> current.expenseQuery
+ },
+ expenseSuggestions = emptyList(),
+ tags = mergedTags,
+ tagInput = "",
+ moreOptionsExpanded = if (shouldExpand) true else current.moreOptionsExpanded,
+ moreOptionsManuallyToggled = shouldExpand || current.moreOptionsManuallyToggled,
+ ),
+ unresolvedCategoryQuery = suggestedCategory.takeIf {
+ it.isNotBlank() && categoryOption == null
+ },
+ unresolvedExpenseQuery = suggestedExpense.takeIf {
+ it.isNotBlank() && expenseOption == null
+ },
+ )
+}
+
+private fun resolveAutoClassifyStatus(
+ suggestedCategory: String,
+ suggestedExpense: String,
+ suggestedTags: List,
+ categoryOption: FilterOption?,
+ expenseOption: FilterOption?,
+): AutoClassifyStatus {
+ val appliedParts = buildList {
+ if (categoryOption != null) add(ClassifyPart.CATEGORY)
+ if (expenseOption != null) add(ClassifyPart.EXPENSE_GROUP)
+ if (suggestedTags.isNotEmpty()) add(ClassifyPart.TAGS)
+ }
+ val unresolvedParts = buildList {
+ if (suggestedCategory.isNotBlank() && categoryOption == null) {
+ add(ClassifyPart.CATEGORY)
+ }
+ if (suggestedExpense.isNotBlank() && expenseOption == null) {
+ add(ClassifyPart.EXPENSE_GROUP)
+ }
+ }
+ return when {
+ appliedParts.isEmpty() && unresolvedParts.isEmpty() -> AutoClassifyStatus.NoSuggestions
+ appliedParts.isNotEmpty() && unresolvedParts.isEmpty() -> AutoClassifyStatus.Applied(appliedParts)
+ appliedParts.isEmpty() -> AutoClassifyStatus.Unresolved(unresolvedParts)
+ else -> AutoClassifyStatus.Partial(applied = appliedParts, unresolved = unresolvedParts)
+ }
+}
diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormCounterpartySearch.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormCounterpartySearch.kt
new file mode 100644
index 0000000..447f7d0
--- /dev/null
+++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormCounterpartySearch.kt
@@ -0,0 +1,37 @@
+package com.pledgerio.app.ui.transactions
+
+import com.pledgerio.app.domain.model.FilterOption
+
+internal fun clearCounterpartySearchState(
+ state: TransactionFormUiState,
+ isSource: Boolean,
+): TransactionFormUiState {
+ return if (isSource) {
+ state.copy(sourceSuggestions = emptyList(), isSearchingSource = false)
+ } else {
+ state.copy(targetSuggestions = emptyList(), isSearchingTarget = false)
+ }
+}
+
+internal fun markCounterpartySearchInProgress(
+ state: TransactionFormUiState,
+ isSource: Boolean,
+): TransactionFormUiState {
+ return if (isSource) {
+ state.copy(isSearchingSource = true)
+ } else {
+ state.copy(isSearchingTarget = true)
+ }
+}
+
+internal fun applyCounterpartySearchSuccess(
+ state: TransactionFormUiState,
+ isSource: Boolean,
+ options: List,
+): TransactionFormUiState {
+ return if (isSource) {
+ state.copy(isSearchingSource = false, sourceSuggestions = options)
+ } else {
+ state.copy(isSearchingTarget = false, targetSuggestions = options)
+ }
+}
diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormEditState.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormEditState.kt
new file mode 100644
index 0000000..eddf53b
--- /dev/null
+++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormEditState.kt
@@ -0,0 +1,66 @@
+package com.pledgerio.app.ui.transactions
+
+import com.pledgerio.app.domain.model.FilterOption
+import com.pledgerio.app.domain.model.Transaction
+import com.pledgerio.app.domain.model.TransactionSplit
+
+internal fun buildStateAfterEditLoad(
+ current: TransactionFormUiState,
+ tx: Transaction,
+ sourceKind: AccountInputKind,
+ targetKind: AccountInputKind,
+ categorySelected: FilterOption?,
+ expenseSelected: FilterOption?,
+ contractSelected: FilterOption?,
+ splitLines: List,
+): TransactionFormUiState {
+ val sourceSelected = tx.toAutocompleteSelection(
+ isSource = true,
+ inputKind = sourceKind,
+ )
+ val targetSelected = tx.toAutocompleteSelection(
+ isSource = false,
+ inputKind = targetKind,
+ )
+ val splitSnapshot: List = tx.split
+
+ return current.copy(
+ isLoading = false,
+ type = tx.type,
+ description = tx.description,
+ amount = tx.amount.toString(),
+ currency = tx.currency,
+ date = tx.date,
+ sourceAccountId = tx.sourceAccountId,
+ sourceSelected = sourceSelected,
+ sourceQuery = tx.sourceAccountName,
+ targetAccountId = tx.destinationAccountId,
+ targetSelected = targetSelected,
+ targetQuery = tx.destinationAccountName,
+ categorySelected = categorySelected,
+ categoryQuery = categorySelected?.label ?: tx.categoryName.orEmpty(),
+ expenseSelected = expenseSelected,
+ expenseQuery = expenseSelected?.label ?: tx.budgetName.orEmpty(),
+ contractSelected = contractSelected,
+ contractQuery = contractSelected?.label ?: tx.contractName.orEmpty(),
+ tags = tx.tags,
+ splitLines = splitLines,
+ originalSplitSnapshot = splitSnapshot,
+ splitSectionExpanded = splitSnapshot.isNotEmpty(),
+ moreOptionsExpanded = tx.tags.isNotEmpty() ||
+ tx.categoryName != null ||
+ tx.budgetName != null ||
+ tx.contractName != null,
+ moreOptionsManuallyToggled = true,
+ )
+}
+
+private fun Transaction.toAutocompleteSelection(
+ isSource: Boolean,
+ inputKind: AccountInputKind,
+): FilterOption? {
+ if (inputKind == AccountInputKind.OWNED_DROPDOWN) return null
+ val accountId = if (isSource) sourceAccountId else destinationAccountId
+ val accountName = if (isSource) sourceAccountName else destinationAccountName
+ return accountId?.let { FilterOption(id = it, label = accountName) }
+}
diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormExperienceMode.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormExperienceMode.kt
new file mode 100644
index 0000000..cb514f8
--- /dev/null
+++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormExperienceMode.kt
@@ -0,0 +1,15 @@
+package com.pledgerio.app.ui.transactions
+
+import com.pledgerio.app.domain.model.FinanceExperienceMode
+
+internal fun resolveMoreOptionsExpansion(
+ previous: TransactionFormUiState,
+ mode: FinanceExperienceMode,
+): Boolean {
+ return when {
+ previous.isEditing -> previous.moreOptionsExpanded
+ previous.moreOptionsManuallyToggled -> previous.moreOptionsExpanded
+ mode == FinanceExperienceMode.POWER -> true
+ else -> false
+ }
+}
diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormSubmission.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormSubmission.kt
new file mode 100644
index 0000000..d549253
--- /dev/null
+++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormSubmission.kt
@@ -0,0 +1,51 @@
+package com.pledgerio.app.ui.transactions
+
+import com.pledgerio.app.domain.model.Account
+import com.pledgerio.app.domain.model.FilterOption
+import com.pledgerio.app.domain.model.Transaction
+
+internal fun resolveAccountDisplayName(
+ accountId: Long?,
+ selected: FilterOption?,
+ ownedAccounts: List,
+): String {
+ selected?.label?.let { return it }
+ return ownedAccounts.find { it.id == accountId }?.name ?: ""
+}
+
+internal fun buildTransactionForSubmit(
+ state: TransactionFormUiState,
+ sourceName: String,
+ targetName: String,
+ categoryId: Long?,
+ expenseId: Long?,
+ contractId: Long?,
+): Transaction {
+ return Transaction(
+ id = state.editingTransactionId ?: 0,
+ description = state.description.trim(),
+ amount = state.amount.toDouble(),
+ currency = state.currency,
+ type = state.type,
+ date = state.date,
+ sourceAccountId = state.sourceAccountId,
+ sourceAccountName = sourceName,
+ destinationAccountId = state.targetAccountId,
+ destinationAccountName = targetName,
+ categoryId = categoryId,
+ expenseId = expenseId,
+ contractId = contractId,
+ tags = state.tags,
+ )
+}
+
+internal suspend fun resolveOptionalSelectionId(
+ selected: FilterOption?,
+ query: String,
+ resolveByName: suspend (String) -> FilterOption?,
+): Long? {
+ selected?.id?.let { return it }
+ val trimmedQuery = query.trim()
+ if (trimmedQuery.isEmpty()) return null
+ return resolveByName(trimmedQuery)?.id
+}
diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt
index 0cff77b..b5cc954 100644
--- a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt
+++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt
@@ -367,13 +367,19 @@ class TransactionFormViewModel @Inject constructor(
val newSourceKind = TransactionFormUiState.inputKindForSource(type)
val newTargetKind = TransactionFormUiState.inputKindForTarget(type)
- val source = preserveSourceSelection(
- current = current,
+ val source = preserveAccountSelection(
+ accountId = current.sourceAccountId,
+ selected = current.sourceSelected,
+ query = current.sourceQuery,
+ ownedAccounts = current.ownedAccounts,
oldKind = oldSourceKind,
newKind = newSourceKind,
)
- val target = preserveTargetSelection(
- current = current,
+ val target = preserveAccountSelection(
+ accountId = current.targetAccountId,
+ selected = current.targetSelected,
+ query = current.targetQuery,
+ ownedAccounts = current.ownedAccounts,
oldKind = oldTargetKind,
newKind = newTargetKind,
)
@@ -595,13 +601,13 @@ class TransactionFormViewModel @Inject constructor(
type = state.type.name,
currency = state.currency,
sourceAccountId = state.sourceAccountId,
- sourceAccountName = resolveAccountName(
+ sourceAccountName = resolveAccountDisplayName(
state.sourceAccountId,
state.sourceSelected,
state.ownedAccounts,
),
targetAccountId = state.targetAccountId,
- targetAccountName = resolveAccountName(
+ targetAccountName = resolveAccountDisplayName(
state.targetAccountId,
state.targetSelected,
state.ownedAccounts,
@@ -777,12 +783,12 @@ class TransactionFormViewModel @Inject constructor(
return
}
- val sourceName = resolveAccountName(
+ val sourceName = resolveAccountDisplayName(
accountId = state.sourceAccountId,
selected = state.sourceSelected,
ownedAccounts = state.ownedAccounts,
).ifBlank { state.sourceQuery.trim() }.takeIf { it.isNotBlank() }
- val targetName = resolveAccountName(
+ val targetName = resolveAccountDisplayName(
accountId = state.targetAccountId,
selected = state.targetSelected,
ownedAccounts = state.ownedAccounts,
@@ -800,99 +806,30 @@ class TransactionFormViewModel @Inject constructor(
) {
is Resource.Success -> {
val suggestion = result.data
- val suggestedCategory = suggestion.category?.trim().orEmpty()
- val suggestedExpense = suggestion.budget?.trim().orEmpty()
- val suggestedTags = suggestion.tags
- .map { it.trim() }
- .filter { it.isNotBlank() }
+ val suggestedCategory = suggestion.category
+ val suggestedExpense = suggestion.budget
val categoryOption = suggestedCategory
+ .orEmpty()
+ .trim()
.takeIf { it.isNotBlank() }
?.let { resolveCategoryOptionByName(it) }
val expenseOption = suggestedExpense
+ .orEmpty()
+ .trim()
.takeIf { it.isNotBlank() }
?.let { resolveExpenseOptionByName(it) }
-
- val appliedParts = buildList {
- if (categoryOption != null) {
- add(com.pledgerio.app.ui.transactions.form.ClassifyPart.CATEGORY)
- }
- if (expenseOption != null) {
- add(com.pledgerio.app.ui.transactions.form.ClassifyPart.EXPENSE_GROUP)
- }
- if (suggestedTags.isNotEmpty()) {
- add(com.pledgerio.app.ui.transactions.form.ClassifyPart.TAGS)
- }
- }
- val unresolvedParts = buildList {
- if (suggestedCategory.isNotBlank() && categoryOption == null) {
- add(com.pledgerio.app.ui.transactions.form.ClassifyPart.CATEGORY)
- }
- if (suggestedExpense.isNotBlank() && expenseOption == null) {
- add(com.pledgerio.app.ui.transactions.form.ClassifyPart.EXPENSE_GROUP)
- }
- }
- val status = when {
- appliedParts.isEmpty() && unresolvedParts.isEmpty() ->
- com.pledgerio.app.ui.transactions.form.AutoClassifyStatus.NoSuggestions
- appliedParts.isNotEmpty() && unresolvedParts.isEmpty() ->
- com.pledgerio.app.ui.transactions.form.AutoClassifyStatus.Applied(appliedParts)
- appliedParts.isEmpty() ->
- com.pledgerio.app.ui.transactions.form.AutoClassifyStatus.Unresolved(unresolvedParts)
- else ->
- com.pledgerio.app.ui.transactions.form.AutoClassifyStatus.Partial(
- applied = appliedParts,
- unresolved = unresolvedParts,
- )
- }
-
- _uiState.update { current ->
- val mergedTags = if (suggestedTags.isNotEmpty()) {
- (current.tags + suggestedTags)
- .distinctBy { it.lowercase() }
- } else {
- current.tags
- }
- val shouldExpand = suggestedCategory.isNotBlank() ||
- suggestedExpense.isNotBlank() ||
- suggestedTags.isNotEmpty()
- current.copy(
- isAutoClassifying = false,
- autoClassifyStatus = status,
- categorySelected = when {
- categoryOption != null -> categoryOption
- suggestedCategory.isNotBlank() -> null
- else -> current.categorySelected
- },
- categoryQuery = when {
- categoryOption != null -> categoryOption.label
- suggestedCategory.isNotBlank() -> suggestedCategory
- else -> current.categoryQuery
- },
- categorySuggestions = emptyList(),
- expenseSelected = when {
- expenseOption != null -> expenseOption
- suggestedExpense.isNotBlank() -> null
- else -> current.expenseSelected
- },
- expenseQuery = when {
- expenseOption != null -> expenseOption.label
- suggestedExpense.isNotBlank() -> suggestedExpense
- else -> current.expenseQuery
- },
- expenseSuggestions = emptyList(),
- tags = mergedTags,
- tagInput = "",
- moreOptionsExpanded = if (shouldExpand) true else current.moreOptionsExpanded,
- moreOptionsManuallyToggled = shouldExpand || current.moreOptionsManuallyToggled,
- )
- }
- if (suggestedCategory.isNotBlank() && categoryOption == null) {
- categoryQueryFlow.value = suggestedCategory
- }
- if (suggestedExpense.isNotBlank() && expenseOption == null) {
- expenseQueryFlow.value = suggestedExpense
- }
+ val applyResult = applyAutoClassifySuggestion(
+ current = _uiState.value,
+ suggestedCategoryRaw = suggestedCategory,
+ suggestedExpenseRaw = suggestedExpense,
+ suggestedTagsRaw = suggestion.tags,
+ categoryOption = categoryOption,
+ expenseOption = expenseOption,
+ )
+ _uiState.update { applyResult.updatedState }
+ applyResult.unresolvedCategoryQuery?.let { categoryQueryFlow.value = it }
+ applyResult.unresolvedExpenseQuery?.let { expenseQueryFlow.value = it }
}
is Resource.Error -> {
@@ -993,32 +930,26 @@ class TransactionFormViewModel @Inject constructor(
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true, error = null) }
- val sourceName = resolveAccountName(
+ val sourceName = resolveAccountDisplayName(
accountId = state.sourceAccountId,
selected = state.sourceSelected,
ownedAccounts = state.ownedAccounts,
)
- val targetName = resolveAccountName(
+ val targetName = resolveAccountDisplayName(
accountId = state.targetAccountId,
selected = state.targetSelected,
ownedAccounts = state.ownedAccounts,
)
-
- val transaction = Transaction(
- id = state.editingTransactionId ?: 0,
- description = state.description.trim(),
- amount = state.amount.toDouble(),
- currency = state.currency,
- type = state.type,
- date = state.date,
- sourceAccountId = state.sourceAccountId,
- sourceAccountName = sourceName,
- destinationAccountId = state.targetAccountId,
- destinationAccountName = targetName,
- categoryId = resolveCategoryId(state),
- expenseId = resolveExpenseId(state),
- contractId = resolveContractId(state),
- tags = state.tags,
+ val categoryId = resolveCategoryId(state)
+ val expenseId = resolveExpenseId(state)
+ val contractId = resolveContractId(state)
+ val transaction = buildTransactionForSubmit(
+ state = state,
+ sourceName = sourceName,
+ targetName = targetName,
+ categoryId = categoryId,
+ expenseId = expenseId,
+ contractId = contractId,
)
if (state.isEditing && state.editingTransactionId != null) {
@@ -1066,83 +997,6 @@ class TransactionFormViewModel @Inject constructor(
_uiState.update { it.copy(isSaving = false, saveSuccess = true) }
}
- private data class PreservedSide(
- val accountId: Long?,
- val selected: FilterOption?,
- val query: String,
- )
-
- private fun preserveSourceSelection(
- current: TransactionFormUiState,
- oldKind: AccountInputKind,
- newKind: AccountInputKind,
- ): PreservedSide {
- if (oldKind != newKind) return PreservedSide(null, null, "")
- return when (newKind) {
- AccountInputKind.OWNED_DROPDOWN -> {
- val id = current.sourceAccountId
- if (id != null && current.ownedAccounts.any { it.id == id }) {
- PreservedSide(id, null, "")
- } else {
- PreservedSide(null, null, "")
- }
- }
- AccountInputKind.CREDITOR_AUTOCOMPLETE,
- AccountInputKind.DEBTOR_AUTOCOMPLETE,
- -> {
- if (current.sourceSelected != null && current.sourceAccountId != null) {
- PreservedSide(
- current.sourceAccountId,
- current.sourceSelected,
- current.sourceQuery,
- )
- } else {
- PreservedSide(null, null, "")
- }
- }
- }
- }
-
- private fun preserveTargetSelection(
- current: TransactionFormUiState,
- oldKind: AccountInputKind,
- newKind: AccountInputKind,
- ): PreservedSide {
- if (oldKind != newKind) return PreservedSide(null, null, "")
- return when (newKind) {
- AccountInputKind.OWNED_DROPDOWN -> {
- val id = current.targetAccountId
- if (id != null && current.ownedAccounts.any { it.id == id }) {
- PreservedSide(id, null, "")
- } else {
- PreservedSide(null, null, "")
- }
- }
- AccountInputKind.CREDITOR_AUTOCOMPLETE,
- AccountInputKind.DEBTOR_AUTOCOMPLETE,
- -> {
- if (current.targetSelected != null && current.targetAccountId != null) {
- PreservedSide(
- current.targetAccountId,
- current.targetSelected,
- current.targetQuery,
- )
- } else {
- PreservedSide(null, null, "")
- }
- }
- }
- }
-
- private fun resolveAccountName(
- accountId: Long?,
- selected: FilterOption?,
- ownedAccounts: List,
- ): String {
- selected?.label?.let { return it }
- return ownedAccounts.find { it.id == accountId }?.name ?: ""
- }
-
private suspend fun resolveCategoryOptionByName(name: String): FilterOption? {
return when (val result = categoryRepository.searchCategories(name)) {
is Resource.Success -> {
@@ -1177,24 +1031,27 @@ class TransactionFormViewModel @Inject constructor(
}
private suspend fun resolveCategoryId(state: TransactionFormUiState): Long? {
- state.categorySelected?.id?.let { return it }
- val name = state.categoryQuery.trim()
- if (name.isEmpty()) return null
- return resolveCategoryOptionByName(name)?.id
+ return resolveOptionalSelectionId(
+ selected = state.categorySelected,
+ query = state.categoryQuery,
+ resolveByName = ::resolveCategoryOptionByName,
+ )
}
private suspend fun resolveExpenseId(state: TransactionFormUiState): Long? {
- state.expenseSelected?.id?.let { return it }
- val name = state.expenseQuery.trim()
- if (name.isEmpty()) return null
- return resolveExpenseOptionByName(name)?.id
+ return resolveOptionalSelectionId(
+ selected = state.expenseSelected,
+ query = state.expenseQuery,
+ resolveByName = ::resolveExpenseOptionByName,
+ )
}
private suspend fun resolveContractId(state: TransactionFormUiState): Long? {
- state.contractSelected?.id?.let { return it }
- val name = state.contractQuery.trim()
- if (name.isEmpty()) return null
- return resolveContractOptionByName(name)?.id
+ return resolveOptionalSelectionId(
+ selected = state.contractSelected,
+ query = state.contractQuery,
+ resolveByName = ::resolveContractOptionByName,
+ )
}
private fun bestMatchOption(name: String, options: List): FilterOption? {
@@ -1228,40 +1085,21 @@ class TransactionFormViewModel @Inject constructor(
isSource: Boolean,
) {
if (query.isBlank()) {
- _uiState.update {
- if (isSource) {
- it.copy(sourceSuggestions = emptyList(), isSearchingSource = false)
- } else {
- it.copy(targetSuggestions = emptyList(), isSearchingTarget = false)
- }
- }
+ _uiState.update { clearCounterpartySearchState(it, isSource = isSource) }
return
}
- _uiState.update {
- if (isSource) it.copy(isSearchingSource = true)
- else it.copy(isSearchingTarget = true)
- }
+ _uiState.update { markCounterpartySearchInProgress(it, isSource = isSource) }
when (val result = accountRepository.searchAccounts(typeCode, query)) {
is Resource.Success -> {
val options = result.data.map { account -> FilterOption(account.id, account.name) }
_uiState.update {
- if (isSource) {
- it.copy(isSearchingSource = false, sourceSuggestions = options)
- } else {
- it.copy(isSearchingTarget = false, targetSuggestions = options)
- }
+ applyCounterpartySearchSuccess(it, isSource = isSource, options = options)
}
}
is Resource.Error -> {
- _uiState.update {
- if (isSource) {
- it.copy(isSearchingSource = false, sourceSuggestions = emptyList())
- } else {
- it.copy(isSearchingTarget = false, targetSuggestions = emptyList())
- }
- }
+ _uiState.update { clearCounterpartySearchState(it, isSource = isSource) }
}
is Resource.Loading -> {}
}
@@ -1285,50 +1123,15 @@ class TransactionFormViewModel @Inject constructor(
val sourceKind = TransactionFormUiState.inputKindForSource(tx.type)
val targetKind = TransactionFormUiState.inputKindForTarget(tx.type)
_uiState.update { state ->
- val sourceSelected = if (
- sourceKind != AccountInputKind.OWNED_DROPDOWN &&
- tx.sourceAccountId != null
- ) {
- FilterOption(tx.sourceAccountId, tx.sourceAccountName)
- } else {
- null
- }
- val targetSelected = if (
- targetKind != AccountInputKind.OWNED_DROPDOWN &&
- tx.destinationAccountId != null
- ) {
- FilterOption(tx.destinationAccountId, tx.destinationAccountName)
- } else {
- null
- }
- state.copy(
- isLoading = false,
- type = tx.type,
- description = tx.description,
- amount = tx.amount.toString(),
- currency = tx.currency,
- date = tx.date,
- sourceAccountId = tx.sourceAccountId,
- sourceSelected = sourceSelected,
- sourceQuery = tx.sourceAccountName,
- targetAccountId = tx.destinationAccountId,
- targetSelected = targetSelected,
- targetQuery = tx.destinationAccountName,
+ buildStateAfterEditLoad(
+ current = state,
+ tx = tx,
+ sourceKind = sourceKind,
+ targetKind = targetKind,
categorySelected = categorySelected,
- categoryQuery = categorySelected?.label ?: tx.categoryName.orEmpty(),
expenseSelected = expenseSelected,
- expenseQuery = expenseSelected?.label ?: tx.budgetName.orEmpty(),
contractSelected = contractSelected,
- contractQuery = contractSelected?.label ?: tx.contractName.orEmpty(),
- tags = tx.tags,
splitLines = tx.split.toSplitLineUi(),
- originalSplitSnapshot = tx.split,
- splitSectionExpanded = tx.split.isNotEmpty(),
- moreOptionsExpanded = tx.tags.isNotEmpty() ||
- tx.categoryName != null ||
- tx.budgetName != null ||
- tx.contractName != null,
- moreOptionsManuallyToggled = true,
)
}
}
@@ -1418,12 +1221,7 @@ class TransactionFormViewModel @Inject constructor(
viewModelScope.launch {
userPreferences.financeExperienceMode.collect { mode ->
val previous = _uiState.value
- val shouldExpandMoreOptions = when {
- previous.isEditing -> previous.moreOptionsExpanded
- previous.moreOptionsManuallyToggled -> previous.moreOptionsExpanded
- mode == FinanceExperienceMode.POWER -> true
- else -> false
- }
+ val shouldExpandMoreOptions = resolveMoreOptionsExpansion(previous, mode)
if (
previous.financeExperienceMode == mode &&
previous.moreOptionsExpanded == shouldExpandMoreOptions
diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionsViewModel.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionsViewModel.kt
index 41ab654..5d3ad08 100644
--- a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionsViewModel.kt
+++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionsViewModel.kt
@@ -12,6 +12,7 @@ import com.pledgerio.app.domain.repository.BudgetRepository
import com.pledgerio.app.domain.repository.CategoryRepository
import com.pledgerio.app.domain.repository.ContractRepository
import com.pledgerio.app.domain.repository.TransactionRepository
+import com.pledgerio.app.domain.usecase.GetTransactionsUseCase
import com.pledgerio.app.util.Resource
import com.pledgerio.app.util.SearchDefaults
import com.pledgerio.app.util.UserPreferences
@@ -81,6 +82,7 @@ data class TransactionsUiState(
@HiltViewModel
class TransactionsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
+ private val getTransactionsUseCase: GetTransactionsUseCase,
private val transactionRepository: TransactionRepository,
private val categoryRepository: CategoryRepository,
private val budgetRepository: BudgetRepository,
@@ -362,7 +364,7 @@ class TransactionsViewModel @Inject constructor(
val startDate = state.currentMonth.atDay(1)
val endDate = state.currentMonth.atEndOfMonth()
- val result = transactionRepository.getTransactionsPage(
+ val result = getTransactionsUseCase(
startDate = startDate,
endDate = endDate,
type = state.selectedType,
@@ -411,7 +413,7 @@ class TransactionsViewModel @Inject constructor(
val startDate = state.currentMonth.atDay(1)
val endDate = state.currentMonth.atEndOfMonth()
- val result = transactionRepository.getTransactionsPage(
+ val result = getTransactionsUseCase(
startDate = startDate,
endDate = endDate,
type = state.selectedType,
diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/detail/TransactionDetailContent.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/detail/TransactionDetailContent.kt
index f603eee..81670e3 100644
--- a/app/src/main/java/com/pledgerio/app/ui/transactions/detail/TransactionDetailContent.kt
+++ b/app/src/main/java/com/pledgerio/app/ui/transactions/detail/TransactionDetailContent.kt
@@ -46,9 +46,9 @@ import com.pledgerio.app.ui.theme.IncomeGreen
import com.pledgerio.app.ui.theme.PledgerGreen
import com.pledgerio.app.ui.theme.PledgerThemeExt
import com.pledgerio.app.ui.transactions.form.TransactionFormLabels
-import com.pledgerio.app.util.CurrencyProvider
import com.pledgerio.app.util.formatCurrency
import com.pledgerio.app.util.formatDisplay
+import java.util.Currency
data class TransactionTypeStyle(
val label: String,
@@ -73,8 +73,8 @@ fun TransactionDetailHeroCard(
modifier: Modifier = Modifier,
) {
val style = transactionTypeStyle(transaction.type)
- val currencyInfo = CurrencyProvider.getInstance()?.get(transaction.currency)
- val currencyLabel = currencyInfo?.let { "${it.symbol} · ${it.code}" } ?: transaction.currency
+ val currency = runCatching { Currency.getInstance(transaction.currency) }.getOrNull()
+ val currencyLabel = currency?.let { "${it.symbol} · ${it.currencyCode}" } ?: transaction.currency
PledgerCard(modifier = modifier) {
Column(
diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/scan/InvoiceScanViewModel.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/scan/InvoiceScanViewModel.kt
index 0830a82..cbe7990 100644
--- a/app/src/main/java/com/pledgerio/app/ui/transactions/scan/InvoiceScanViewModel.kt
+++ b/app/src/main/java/com/pledgerio/app/ui/transactions/scan/InvoiceScanViewModel.kt
@@ -5,9 +5,8 @@ import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pledgerio.app.R
-import com.pledgerio.app.data.ocr.InvoiceTextExtractor
import com.pledgerio.app.domain.model.TransactionExtractionDraft
-import com.pledgerio.app.domain.repository.TransactionRepository
+import com.pledgerio.app.domain.usecase.ProcessInvoiceScanUseCase
import com.pledgerio.app.util.Resource
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -38,8 +37,7 @@ data class InvoiceScanUiState(
@HiltViewModel
class InvoiceScanViewModel @Inject constructor(
@ApplicationContext private val context: Context,
- private val invoiceTextExtractor: InvoiceTextExtractor,
- private val transactionRepository: TransactionRepository,
+ private val processInvoiceScanUseCase: ProcessInvoiceScanUseCase,
) : ViewModel() {
private val _uiState = MutableStateFlow(InvoiceScanUiState())
@@ -65,7 +63,7 @@ class InvoiceScanViewModel @Inject constructor(
draft = null,
)
}
- when (val ocrResult = invoiceTextExtractor.extractText(uri)) {
+ when (val ocrResult = processInvoiceScanUseCase.extractTextFromImage(uri.toString())) {
is Resource.Success -> {
_uiState.update {
it.copy(
@@ -124,12 +122,12 @@ class InvoiceScanViewModel @Inject constructor(
draft = null,
)
}
- when (val result = transactionRepository.extractTransactionFromText(text)) {
+ when (val result = processInvoiceScanUseCase.extractDraftFromText(text)) {
is Resource.Success -> {
_uiState.update {
it.copy(
stage = InvoiceScanStage.IDLE,
- draft = result.data.copy(rawText = text),
+ draft = result.data,
)
}
}
diff --git a/app/src/main/java/com/pledgerio/app/util/CurrencyProvider.kt b/app/src/main/java/com/pledgerio/app/util/CurrencyProvider.kt
index 7250179..17d03ad 100644
--- a/app/src/main/java/com/pledgerio/app/util/CurrencyProvider.kt
+++ b/app/src/main/java/com/pledgerio/app/util/CurrencyProvider.kt
@@ -43,15 +43,4 @@ class CurrencyProvider @Inject constructor(
"$currencyCode $formatted"
}
}
-
- companion object {
- @Volatile
- private var instance: CurrencyProvider? = null
-
- fun getInstance(): CurrencyProvider? = instance
-
- internal fun setInstance(provider: CurrencyProvider) {
- instance = provider
- }
- }
}
diff --git a/app/src/main/java/com/pledgerio/app/util/Extensions.kt b/app/src/main/java/com/pledgerio/app/util/Extensions.kt
index a6218f6..228746e 100644
--- a/app/src/main/java/com/pledgerio/app/util/Extensions.kt
+++ b/app/src/main/java/com/pledgerio/app/util/Extensions.kt
@@ -2,15 +2,15 @@ package com.pledgerio.app.util
import java.time.LocalDate
import java.time.format.DateTimeFormatter
+import java.util.Currency
fun Double.formatCurrency(currencyCode: String? = null): String {
val code = currencyCode ?: UserPreferences.defaultDisplayCurrency
- val provider = CurrencyProvider.getInstance()
- if (provider != null) {
- return provider.formatAmount(this, code)
- }
- val formatted = String.format("%,.2f", this)
- return "$code $formatted"
+ val javaCurrency = runCatching { Currency.getInstance(code) }.getOrNull()
+ val decimalPlaces = javaCurrency?.defaultFractionDigits?.takeIf { it >= 0 } ?: 2
+ val symbol = javaCurrency?.symbol ?: code
+ val formatted = String.format("%,.${decimalPlaces}f", this)
+ return "$symbol $formatted"
}
fun LocalDate.formatDisplay(): String {
diff --git a/app/src/main/java/com/pledgerio/app/util/SyncWorker.kt b/app/src/main/java/com/pledgerio/app/util/SyncWorker.kt
index 6b8fb2b..4fc7c71 100644
--- a/app/src/main/java/com/pledgerio/app/util/SyncWorker.kt
+++ b/app/src/main/java/com/pledgerio/app/util/SyncWorker.kt
@@ -23,9 +23,12 @@ import com.pledgerio.app.domain.repository.CurrencyRepository
import com.pledgerio.app.domain.repository.TagRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
+import java.io.IOException
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.CancellationException
import java.time.LocalDate
import java.util.concurrent.TimeUnit
+import retrofit2.HttpException
@HiltWorker
class SyncWorker @AssistedInject constructor(
@@ -60,8 +63,14 @@ class SyncWorker @AssistedInject constructor(
}
Result.success()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: IOException) {
+ classifyFailure(e)
+ } catch (e: HttpException) {
+ classifyFailure(e)
} catch (e: Exception) {
- Result.retry()
+ classifyFailure(e)
}
}
@@ -103,6 +112,16 @@ class SyncWorker @AssistedInject constructor(
private const val NOTIFICATION_ID = 1001
private const val WORK_NAME = "pledger_sync"
+ internal fun classifyFailure(error: Throwable): Result = when (error) {
+ is IOException -> Result.retry()
+ is HttpException -> if (error.code() == 401 || error.code() == 403) {
+ Result.failure()
+ } else {
+ Result.retry()
+ }
+ else -> Result.failure()
+ }
+
fun cancel(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
}
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
index d7b4192..683208f 100644
--- a/app/src/main/res/xml/network_security_config.xml
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -1,6 +1,6 @@
-
+
diff --git a/app/src/test/java/com/pledgerio/app/data/local/PledgerDatabaseMigrationsTest.kt b/app/src/test/java/com/pledgerio/app/data/local/PledgerDatabaseMigrationsTest.kt
new file mode 100644
index 0000000..da118f7
--- /dev/null
+++ b/app/src/test/java/com/pledgerio/app/data/local/PledgerDatabaseMigrationsTest.kt
@@ -0,0 +1,38 @@
+package com.pledgerio.app.data.local
+
+import androidx.sqlite.db.SupportSQLiteDatabase
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Test
+
+class PledgerDatabaseMigrationsTest {
+
+ @Test
+ fun `migration 5 to 6 creates account_types and sync_metadata tables`() {
+ val db = mockk(relaxed = true)
+
+ PledgerDatabaseMigrations.MIGRATION_5_6.migrate(db)
+
+ verify {
+ db.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS `account_types` (
+ `code` TEXT NOT NULL,
+ PRIMARY KEY(`code`)
+ )
+ """.trimIndent(),
+ )
+ }
+ verify {
+ db.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS `sync_metadata` (
+ `key` TEXT NOT NULL,
+ `lastSyncedAt` INTEGER NOT NULL,
+ PRIMARY KEY(`key`)
+ )
+ """.trimIndent(),
+ )
+ }
+ }
+}
diff --git a/app/src/test/java/com/pledgerio/app/data/remote/api/AuthInterceptorTest.kt b/app/src/test/java/com/pledgerio/app/data/remote/api/AuthInterceptorTest.kt
new file mode 100644
index 0000000..e609f9b
--- /dev/null
+++ b/app/src/test/java/com/pledgerio/app/data/remote/api/AuthInterceptorTest.kt
@@ -0,0 +1,113 @@
+package com.pledgerio.app.data.remote.api
+
+import com.pledgerio.app.data.local.LocalDataCleaner
+import com.pledgerio.app.util.SessionManager
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.verify
+import okhttp3.Interceptor
+import okhttp3.Protocol
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+
+class AuthInterceptorTest {
+
+ private lateinit var sessionManager: SessionManager
+ private lateinit var tokenRefresher: TokenRefresher
+ private lateinit var localDataCleaner: LocalDataCleaner
+ private lateinit var interceptor: AuthInterceptor
+
+ @Before
+ fun setUp() {
+ sessionManager = mockk(relaxed = true)
+ tokenRefresher = mockk(relaxed = true)
+ localDataCleaner = mockk(relaxed = true)
+ interceptor = AuthInterceptor(sessionManager, tokenRefresher, localDataCleaner)
+ }
+
+ @Test
+ fun `intercept retries with refreshed token after unauthorized response`() {
+ val original = Request.Builder()
+ .url("https://example.com/v2/api/accounts")
+ .build()
+ val chain = mockk()
+
+ every { chain.request() } returns original
+ every { sessionManager.getToken() } returnsMany listOf("old-token", "new-token")
+ every { tokenRefresher.refreshToken() } returns true
+ every { localDataCleaner.clearAllUserDataAsync() } just Runs
+
+ val capturedRequests = mutableListOf()
+ every { chain.proceed(capture(capturedRequests)) } returnsMany listOf(
+ responseFor(original, 401),
+ responseFor(original, 200),
+ )
+
+ val result = interceptor.intercept(chain)
+
+ assertEquals(200, result.code)
+ assertEquals(2, capturedRequests.size)
+ assertEquals("Bearer old-token", capturedRequests[0].header("Authorization"))
+ assertEquals("Bearer new-token", capturedRequests[1].header("Authorization"))
+ verify(exactly = 1) { tokenRefresher.refreshTokenIfNeeded() }
+ verify(exactly = 1) { tokenRefresher.refreshToken() }
+ verify(exactly = 0) { localDataCleaner.clearAllUserDataAsync() }
+ verify(exactly = 0) { sessionManager.clearAuthTokens() }
+ }
+
+ @Test
+ fun `intercept clears auth state when token refresh fails after unauthorized`() {
+ val original = Request.Builder()
+ .url("https://example.com/v2/api/accounts")
+ .build()
+ val chain = mockk()
+
+ every { chain.request() } returns original
+ every { sessionManager.getToken() } returns "old-token"
+ every { tokenRefresher.refreshToken() } returns false
+ every { chain.proceed(any()) } returns responseFor(original, 401)
+ every { localDataCleaner.clearAllUserDataAsync() } just Runs
+ every { sessionManager.clearAuthTokens() } just Runs
+
+ val result = interceptor.intercept(chain)
+
+ assertEquals(401, result.code)
+ verify(exactly = 1) { tokenRefresher.refreshTokenIfNeeded() }
+ verify(exactly = 1) { tokenRefresher.refreshToken() }
+ verify(exactly = 1) { localDataCleaner.clearAllUserDataAsync() }
+ verify(exactly = 1) { sessionManager.clearAuthTokens() }
+ }
+
+ @Test
+ fun `intercept skips proactive refresh for auth endpoints`() {
+ val authRequest = Request.Builder()
+ .url("https://example.com/v2/api/security/authenticate")
+ .build()
+ val chain = mockk()
+
+ every { chain.request() } returns authRequest
+ every { sessionManager.getToken() } returns null
+ every { chain.proceed(any()) } returns responseFor(authRequest, 200)
+
+ interceptor.intercept(chain)
+
+ verify(exactly = 0) { tokenRefresher.refreshTokenIfNeeded() }
+ verify(exactly = 0) { tokenRefresher.refreshToken() }
+ }
+
+ private fun responseFor(request: Request, code: Int): Response =
+ Response.Builder()
+ .request(request)
+ .protocol(Protocol.HTTP_1_1)
+ .code(code)
+ .message("test")
+ .body("{}".toByteArray().toResponseBody("application/json".toMediaType()))
+ .build()
+}
diff --git a/app/src/test/java/com/pledgerio/app/ui/budgets/BudgetsViewModelTest.kt b/app/src/test/java/com/pledgerio/app/ui/budgets/BudgetsViewModelTest.kt
index e13190b..851355e 100644
--- a/app/src/test/java/com/pledgerio/app/ui/budgets/BudgetsViewModelTest.kt
+++ b/app/src/test/java/com/pledgerio/app/ui/budgets/BudgetsViewModelTest.kt
@@ -2,13 +2,14 @@ package com.pledgerio.app.ui.budgets
import androidx.lifecycle.SavedStateHandle
import com.pledgerio.app.domain.model.BudgetListState
-import com.pledgerio.app.domain.repository.BudgetRepository
import com.pledgerio.app.domain.usecase.CreateInitialBudgetUseCase
+import com.pledgerio.app.domain.usecase.GetBudgetsUseCase
import com.pledgerio.app.domain.usecase.SaveBudgetExpenseUseCase
import com.pledgerio.app.util.Resource
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
@@ -22,17 +23,18 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
+@OptIn(ExperimentalCoroutinesApi::class)
class BudgetsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
- private val budgetRepository = mockk()
+ private val getBudgetsUseCase = mockk()
private val createInitialBudgetUseCase = mockk()
private val saveBudgetExpenseUseCase = mockk(relaxed = true)
private val savedStateHandle = SavedStateHandle(mapOf("year" to -1, "month" to -1))
private fun createViewModel() = BudgetsViewModel(
savedStateHandle = savedStateHandle,
- budgetRepository = budgetRepository,
+ getBudgetsUseCase = getBudgetsUseCase,
createInitialBudgetUseCase = createInitialBudgetUseCase,
saveBudgetExpenseUseCase = saveBudgetExpenseUseCase,
)
@@ -49,7 +51,7 @@ class BudgetsViewModelTest {
@Test
fun `load sets needsInitialSetup when repository returns 404 state`() = runTest {
- every { budgetRepository.getBudgets(any(), any()) } returns flowOf(
+ every { getBudgetsUseCase(any(), any()) } returns flowOf(
Resource.Loading,
Resource.Success(BudgetListState(needsInitialSetup = true)),
)
@@ -64,7 +66,7 @@ class BudgetsViewModelTest {
@Test
fun `createInitialBudget reloads after success`() = runTest {
var loadCount = 0
- every { budgetRepository.getBudgets(any(), any()) } answers {
+ every { getBudgetsUseCase(any(), any()) } answers {
loadCount++
if (loadCount == 1) {
flowOf(
diff --git a/app/src/test/java/com/pledgerio/app/ui/reports/ReportsViewModelTest.kt b/app/src/test/java/com/pledgerio/app/ui/reports/ReportsViewModelTest.kt
new file mode 100644
index 0000000..0b3fdd2
--- /dev/null
+++ b/app/src/test/java/com/pledgerio/app/ui/reports/ReportsViewModelTest.kt
@@ -0,0 +1,78 @@
+package com.pledgerio.app.ui.reports
+
+import com.pledgerio.app.domain.model.IncomeExpenseSummary
+import com.pledgerio.app.domain.model.ReportsOverview
+import com.pledgerio.app.domain.repository.ReportRepository
+import com.pledgerio.app.domain.repository.ReportsOverviewStore
+import com.pledgerio.app.util.MainDispatcherRule
+import com.pledgerio.app.util.Resource
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Rule
+import org.junit.Test
+import java.time.YearMonth
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ReportsViewModelTest {
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ private val reportRepository = mockk()
+ private val overviewStore = mockk()
+
+ @Test
+ fun `init uses fresh cached overview without network calls`() = runTest {
+ val month = YearMonth.now()
+ val cachedOverview = ReportsOverview(
+ incomeExpense = IncomeExpenseSummary(income = 1200.0, expense = 800.0),
+ )
+ val entry = mockk()
+ every { entry.overview } returns cachedOverview
+ every { entry.fetchedAtMillis } returns 1234L
+ every { entry.isFresh(month, any(), any()) } returns true
+ coEvery { overviewStore.get(month) } returns entry
+
+ val viewModel = ReportsViewModel(reportRepository, overviewStore)
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ assertEquals(cachedOverview, state.overview)
+ assertFalse(state.isLoading)
+ assertFalse(state.isRefreshing)
+ coVerify(exactly = 0) { reportRepository.getIncomeExpenseSummary(any()) }
+ }
+
+ @Test
+ fun `refresh invalidates cache and fetches overview from repositories`() = runTest {
+ val month = YearMonth.now()
+ coEvery { overviewStore.get(any()) } returns null
+ coEvery { overviewStore.invalidate(month) } returns Unit
+ coEvery { overviewStore.put(any(), any(), any()) } returns Unit
+ coEvery { reportRepository.getIncomeExpenseSummary(month) } returns
+ Resource.Success(IncomeExpenseSummary(income = 50.0, expense = 20.0))
+ coEvery { reportRepository.getCategoryBreakdown(month) } returns Resource.Success(emptyList())
+ coEvery { reportRepository.getAccountBalances(month) } returns Resource.Success(emptyList())
+ coEvery { reportRepository.getBudgetPerformance(month) } returns Resource.Success(emptyList())
+ coEvery { reportRepository.getNetWorthTrend(month) } returns Resource.Success(emptyList())
+
+ val viewModel = ReportsViewModel(reportRepository, overviewStore)
+ advanceUntilIdle()
+ viewModel.refresh()
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ assertFalse(state.isLoading)
+ assertFalse(state.isRefreshing)
+ assertEquals(50.0, state.overview?.incomeExpense?.income ?: 0.0, 0.0)
+ coVerify(atLeast = 1) { overviewStore.invalidate(month) }
+ coVerify(atLeast = 1) { overviewStore.put(month, any(), any()) }
+ }
+}
diff --git a/app/src/test/java/com/pledgerio/app/ui/transactions/TransactionsViewModelTest.kt b/app/src/test/java/com/pledgerio/app/ui/transactions/TransactionsViewModelTest.kt
index e79ac63..054b3a6 100644
--- a/app/src/test/java/com/pledgerio/app/ui/transactions/TransactionsViewModelTest.kt
+++ b/app/src/test/java/com/pledgerio/app/ui/transactions/TransactionsViewModelTest.kt
@@ -7,6 +7,7 @@ import com.pledgerio.app.domain.repository.PagedResult
import com.pledgerio.app.domain.repository.TransactionRepository
import androidx.lifecycle.SavedStateHandle
import com.pledgerio.app.domain.model.FinanceExperienceMode
+import com.pledgerio.app.domain.usecase.GetTransactionsUseCase
import com.pledgerio.app.util.MainDispatcherRule
import com.pledgerio.app.util.Resource
import com.pledgerio.app.util.UserPreferences
@@ -32,6 +33,7 @@ class TransactionsViewModelTest {
val mainDispatcherRule = MainDispatcherRule()
private val transactionRepository = mockk()
+ private val getTransactionsUseCase = mockk()
private val categoryRepository = mockk(relaxed = true)
private val budgetRepository = mockk(relaxed = true)
private val contractRepository = mockk(relaxed = true)
@@ -47,6 +49,7 @@ class TransactionsViewModelTest {
private fun createViewModel() = TransactionsViewModel(
savedStateHandle = savedStateHandle,
+ getTransactionsUseCase = getTransactionsUseCase,
transactionRepository = transactionRepository,
categoryRepository = categoryRepository,
budgetRepository = budgetRepository,
@@ -58,7 +61,7 @@ class TransactionsViewModelTest {
fun `navigateToMonth keeps selected month when API returns no transactions`() = runTest {
val target = YearMonth.of(2024, 3)
coEvery {
- transactionRepository.getTransactionsPage(
+ getTransactionsUseCase(
startDate = any(),
endDate = any(),
accountId = any(),
@@ -88,7 +91,7 @@ class TransactionsViewModelTest {
@Test
fun `nextMonth from previous month keeps current month when empty`() = runTest {
coEvery {
- transactionRepository.getTransactionsPage(
+ getTransactionsUseCase(
startDate = any(),
endDate = any(),
accountId = any(),
diff --git a/app/src/test/java/com/pledgerio/app/util/SyncWorkerTest.kt b/app/src/test/java/com/pledgerio/app/util/SyncWorkerTest.kt
new file mode 100644
index 0000000..74c093f
--- /dev/null
+++ b/app/src/test/java/com/pledgerio/app/util/SyncWorkerTest.kt
@@ -0,0 +1,44 @@
+package com.pledgerio.app.util
+
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import retrofit2.HttpException
+import retrofit2.Response
+import java.io.IOException
+
+class SyncWorkerTest {
+
+ @Test
+ fun `classifyFailure retries on io exceptions`() {
+ val result = SyncWorker.classifyFailure(IOException("network"))
+ assertEquals("Retry", result.javaClass.simpleName)
+ }
+
+ @Test
+ fun `classifyFailure fails on unauthorized`() {
+ val result = SyncWorker.classifyFailure(httpException(401))
+ assertEquals("Failure", result.javaClass.simpleName)
+ }
+
+ @Test
+ fun `classifyFailure retries on server http errors`() {
+ val result = SyncWorker.classifyFailure(httpException(500))
+ assertEquals("Retry", result.javaClass.simpleName)
+ }
+
+ @Test
+ fun `classifyFailure fails on unknown exception`() {
+ val result = SyncWorker.classifyFailure(IllegalStateException("boom"))
+ assertEquals("Failure", result.javaClass.simpleName)
+ }
+
+ private fun httpException(code: Int): HttpException {
+ val response = Response.error(
+ code,
+ "{}".toResponseBody("application/json".toMediaType()),
+ )
+ return HttpException(response)
+ }
+}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index e77a46a..d993b4a 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -125,7 +125,7 @@ See [Budgets](BUDGETS.md) for API and screen details.
### Data Layer (`data/`)
- **Remote** — `PledgerApiService`, Moshi DTOs (`@JsonClass(generateAdapter = true)`).
-- **Local** — Room (`PledgerDatabase` v5): accounts, transactions, budgets, categories, currencies, contracts, expense groups, account types, sync metadata.
+- **Local** — Room (`PledgerDatabase` v6): accounts, transactions, budgets, categories, currencies, contracts, expense groups, account types, sync metadata.
- **Repositories** — Stale-while-revalidate cache for reference data, network-first for paged data:
1. Read returns cached values from Room immediately (via Flow or one-shot query).
@@ -198,6 +198,8 @@ Repositories return `Resource`:
- `Resource.Error(message)`
- `Resource.Loading`
+`Resource` is defined in `domain/common` (with a temporary compatibility alias in `util` while imports are migrated).
+
## Offline Strategy
### Room cache
@@ -236,7 +238,7 @@ Transactions are refreshed on screen load rather than in the worker.
| Concern | Implementation |
|---------|----------------|
| Token storage | `EncryptedSharedPreferences` (AES-256-GCM) |
-| Transport | User-supplied URL; cleartext permitted in debug config for dev servers |
+| Transport | User-supplied URL; cleartext denied by default and enabled only in debug override config |
| Biometric | Optional via settings (`BiometricPrompt`) |
| Session expiry | Proactive JWT refresh + 401 retry; `clearAuthTokens` keeps server URL |
| Platform | compile/target SDK 36 (Android 16); edge-to-edge in `MainActivity` |
@@ -274,7 +276,7 @@ Navigation arguments via `SavedStateHandle`. No Android framework types inside V
| Use cases | JUnit, MockK, coroutines-test |
| ViewModels | JUnit, MockK, Turbine |
| Repositories | JUnit, MockK |
-| UI | Compose UI tests for critical flows |
+| UI | Compose UI tests for critical flows (`androidTest` smoke coverage in CI) |
## Module Boundaries
diff --git a/docs/CODEBASE_IMPROVEMENT_AUDIT.md b/docs/CODEBASE_IMPROVEMENT_AUDIT.md
new file mode 100644
index 0000000..9a7f225
--- /dev/null
+++ b/docs/CODEBASE_IMPROVEMENT_AUDIT.md
@@ -0,0 +1,212 @@
+# Mobile Application Improvement Audit
+
+**Project:** `pledger-io/android-app`
+**Date:** 2026-05-24
+**Audit type:** Static code and configuration review (architecture, security, reliability, testing, and maintainability)
+
+## Goal
+
+This document captures concrete improvements to strengthen the Android app quality, reduce production risk, and increase delivery speed. Recommendations are prioritized so the team can execute in phases.
+
+## Implementation Progress
+
+Legend: `[x]` done, `[~]` in progress, `[ ]` not started.
+
+- [x] Critical #1: release-safe logging (`BODY` in debug, `NONE` in release).
+- [x] Critical #2: cleartext disabled by default with debug-only override.
+- [x] High #3: destructive migration fallback removed; explicit migration scaffold added; Room schema export enabled.
+- [x] High #4: blocking interceptor cleanup removed (`clearAllUserDataAsync` on app scope).
+- [x] High #6: UI/data layering tightened for reports and invoice-scan (`ReportsOverviewStore`, `ProcessInvoiceScanUseCase`, `InvoiceTextReader`).
+- [~] High #5/#7: major use-case consistency cleanup completed (`LoginUseCase`, `GetDashboardDataUseCase`, `GetBudgetsUseCase`, `GetTransactionsUseCase` wired in ViewModels) and transaction-form helpers extracted (account preservation + submission/id-resolution + auto-classify apply + counterparty-search state handling + edit-load state mapping + experience-mode expansion decision); additional decomposition still possible.
+- [x] High #8: instrumented/UI test coverage started (`androidTest` smoke test + CI instrumented job).
+- [x] Medium #9: `allowBackup` hardened (`false`).
+- [x] Medium #10: `CurrencyProvider` singleton access removed (`getInstance`/`setInstance` retired; formatting no longer relies on static provider state).
+- [~] Medium #11: migration bridge added and domain-layer imports moved to `domain/common/Resource`; data/ui layers still pending for full completion.
+- [~] Medium #12: transaction offline fallback improved for date/type/text filters and now explicitly errors for unsupported offline ID filters (category/expense/contract); full offline parity still pending schema/API alignment.
+- [x] Medium #13: `SyncWorker` now classifies transient vs permanent failures (`IOException` retry, auth failures fail).
+- [x] Medium #14: CI lint gate added (`lintDebug`) with baseline to enforce no new lint debt.
+- [x] Low #15: release hygiene improved (non-static `versionCode` via env/CI and required release signing in release workflow); docs aligned.
+- [x] Test expansion #1: added `AuthInterceptor` tests.
+- [x] Test expansion #2: added `ReportsViewModel` cache/refresh unit coverage.
+- [x] Test expansion #3 (partial): login flow now executes through `LoginUseCase` in production code paths.
+- [x] Test expansion #4 (partial): added `SyncWorker` failure-classification unit coverage.
+- [x] Test expansion #6 (initial): added first Compose `androidTest` smoke test (`MainActivitySmokeTest`).
+- [x] Test expansion #5 (initial): added migration test scaffolding for 5→6.
+
+## What is working well
+
+- Clear package structure with `data` / `domain` / `ui` separation and ADR documentation.
+- Strong repository-level unit testing footprint.
+- Solid foundations for offline-first behavior (Room + cache policy + sync metadata).
+- Good auth/session primitives (`SessionManager`, token refresh, logout cleanup).
+- Thoughtful product UX direction documented in feature plans.
+
+## Priority Findings
+
+### Critical
+
+1. **Release logging leaks sensitive API payloads**
+ - Evidence: `NetworkModule` always sets `HttpLoggingInterceptor.Level.BODY`.
+ - Risk: JWTs, credentials, and financial payloads can appear in logs.
+ - Recommendation:
+ - Use `if (BuildConfig.DEBUG) BODY else NONE`.
+ - Keep issue-report logging sanitized and metadata-only.
+
+2. **Cleartext traffic currently permitted globally**
+ - Evidence: `network_security_config.xml` has ``.
+ - Risk: Release traffic can be sent over insecure HTTP.
+ - Recommendation:
+ - Restrict cleartext to debug only (debug config/flavor).
+ - Enforce HTTPS in release builds.
+ - Align README wording to actual behavior.
+
+### High
+
+3. **Destructive Room migration policy**
+ - Evidence: `fallbackToDestructiveMigration()` + `exportSchema = false`.
+ - Risk: app upgrades can wipe local data/cache silently.
+ - Recommendation:
+ - Add explicit Room migrations.
+ - Turn on schema export and commit schema history.
+ - Remove destructive fallback for release.
+
+4. **Blocking cleanup call inside network interceptor**
+ - Evidence: `AuthInterceptor` calls `clearAllUserDataBlocking()` (uses `runBlocking`).
+ - Risk: thread blocking in OkHttp chain, potential latency spikes.
+ - Recommendation:
+ - Return auth failure immediately.
+ - Trigger async session-expired cleanup on app scope.
+ - Keep interceptor non-blocking.
+
+5. **Very large ViewModels increase change risk**
+ - Evidence: `TransactionFormViewModel` and `TransactionsViewModel` hold multiple responsibilities.
+ - Risk: regression probability and test complexity rise with each feature.
+ - Recommendation:
+ - Extract state reducers, input validation, filter orchestration, and side-effect handlers.
+ - Move orchestration into focused domain/use-case components.
+
+6. **Layering violations from UI directly depending on data-layer internals**
+ - Evidence: `ReportsViewModel` depends on `ReportsOverviewCache`; `InvoiceScanViewModel` depends on `InvoiceTextExtractor`.
+ - Risk: weak architectural boundaries and harder replacements/testing.
+ - Recommendation:
+ - Access through domain-level interfaces/use cases only.
+ - Keep `ui` unaware of data implementation types.
+
+7. **Domain use-case layer is inconsistent**
+ - Evidence: some use cases are used (`CreateInitialBudgetUseCase`, `SaveBudgetExpenseUseCase`), while others exist but are not wired (`LoginUseCase`, `GetDashboardDataUseCase`, `GetTransactionsUseCase`, `GetBudgetsUseCase`).
+ - Risk: duplicate orchestration patterns and architectural drift.
+ - Recommendation:
+ - Decide one direction:
+ - Wire use cases consistently for all major flows, or
+ - Remove unused use cases and simplify docs.
+
+8. **No instrumented/UI test coverage**
+ - Evidence: no files in `app/src/androidTest`.
+ - Risk: navigation, compose rendering, and integration regressions escape unit tests.
+ - Recommendation:
+ - Add smoke UI tests for onboarding/login/main-tab navigation/transaction list.
+ - Add a CI job for instrumentation tests (managed emulator or device lab).
+
+### Medium
+
+9. **Backup posture should be tightened**
+ - Evidence: `android:allowBackup="true"` in manifest.
+ - Risk: session-adjacent data may be restorable on some device/backup combinations.
+ - Recommendation:
+ - Set `allowBackup=false` or add strict backup rules excluding secure/session stores.
+
+10. **Global singleton usage (`CurrencyProvider`) bypasses DI**
+ - Evidence: `CurrencyProvider.getInstance()` accessed during cleanup.
+ - Risk: hidden coupling and harder testability/lifecycle control.
+ - Recommendation:
+ - Replace singleton access with injected abstraction across app.
+
+11. **`Resource` type location blurs boundaries**
+ - Evidence: `Resource` resides in `util` but is used across domain and data.
+ - Risk: cross-layer coupling through a generic utility namespace.
+ - Recommendation:
+ - Move to a dedicated `domain/common` location or migrate to `Result` + typed domain errors.
+
+12. **Offline behavior for transactions is partial**
+ - Evidence: limited fallback behavior in transaction paging/filtering paths.
+ - Risk: inconsistent user experience when offline.
+ - Recommendation:
+ - Define explicit cache semantics per filter/page, or
+ - Explicitly message which query modes are online-only.
+
+13. **WorkManager retry policy is broad**
+ - Evidence: worker retry path is not strongly classified by error type.
+ - Risk: repeated retries for permanent failures.
+ - Recommendation:
+ - Classify transient vs permanent failures.
+ - Use backoff policies and fail fast on unrecoverable cases.
+
+14. **Static analysis gates are missing in CI**
+ - Evidence: CI runs unit tests + debug build only.
+ - Risk: style, correctness, and Android lint issues slip into main.
+ - Recommendation:
+ - Add `lintDebug`.
+ - Add Detekt/ktlint (or equivalent) with baseline and fail-on-new policy.
+
+### Low
+
+15. **Release hygiene and doc drift**
+ - Evidence:
+ - `versionCode = 1` is static.
+ - README says Room schema v3 while DB is v6.
+ - Release signing falls back to debug key when missing config.
+ - Recommendation:
+ - Enforce versioning policy.
+ - Keep docs synced with implementation.
+ - Fail release build when production signing material is absent.
+
+## Recommended Test Expansion
+
+Add focused tests in this order:
+
+1. `AuthInterceptor` behavior (401 retry path, token refresh fail path, auth endpoint bypass).
+2. `ReportsViewModel` cache + refresh behavior.
+3. `LoginViewModel` and server setup validation behavior.
+4. `SyncWorker` using test worker builder + fake repositories.
+5. Room migration tests for each schema upgrade.
+6. Compose smoke tests for critical flows in `androidTest`.
+
+## 30 / 60 / 90 Day Execution Plan
+
+## 0-30 days (Risk reduction)
+
+- Fix logging level for release.
+- Restrict cleartext to debug only.
+- Remove blocking cleanup from interceptor path.
+- Add lint gate to CI.
+- Start instrumented smoke tests.
+
+## 31-60 days (Architecture hardening)
+
+- Implement Room migrations + schema export.
+- Refactor reports/invoice scan to domain-facing interfaces.
+- Split transaction ViewModel responsibilities.
+- Resolve use-case consistency decision and apply it.
+
+## 61-90 days (Quality at scale)
+
+- Expand UI/integration test suite.
+- Improve offline consistency strategy for transactions/reports.
+- Adopt static analysis stack fully (lint + detekt + formatting checks).
+- Strengthen release process checks (signing/version/docs sync).
+
+## Suggested Tracking Format
+
+Create epics/issues grouped by:
+
+- **Security hardening**
+- **Data integrity and migrations**
+- **Architecture and maintainability**
+- **Test coverage and CI quality gates**
+- **Release engineering**
+
+Each issue should include: owner, effort estimate, risk level, acceptance criteria, and rollback strategy.
+
+---
+
+If needed, this audit can be converted into a sprint-by-sprint issue backlog with file-level task breakdowns.
diff --git a/docs/adr/015-stale-while-revalidate-cache.md b/docs/adr/015-stale-while-revalidate-cache.md
index 0e694ef..00a85ff 100644
--- a/docs/adr/015-stale-while-revalidate-cache.md
+++ b/docs/adr/015-stale-while-revalidate-cache.md
@@ -75,8 +75,8 @@ Room version bumped to **5**. New entities:
counterparties can live in the same table as owned accounts without wiping each other on
refresh.
-`fallbackToDestructiveMigration()` remains acceptable because the cache is always rehydrated
-from the server.
+Database migrations are explicitly versioned; destructive fallback is not used in production
+paths so cache/index evolution stays predictable across upgrades.
## Consequences
diff --git a/docs/adr/019-security-and-quality-hardening.md b/docs/adr/019-security-and-quality-hardening.md
new file mode 100644
index 0000000..4475cb4
--- /dev/null
+++ b/docs/adr/019-security-and-quality-hardening.md
@@ -0,0 +1,61 @@
+# ADR-019: Security and Quality Hardening Baseline
+
+**Date:** 2026-05-24
+**Status:** Accepted
+
+## Context
+
+The Android app had several production-risk defaults that were acceptable during rapid feature
+delivery but not suitable as a long-term baseline:
+
+- HTTP body logging was enabled for all builds.
+- Cleartext HTTP was globally allowed.
+- Room was configured with destructive fallback.
+- CI lacked lint gates and instrumented validation.
+- Some UI flows depended directly on data-layer implementations.
+
+## Decision
+
+Adopt a hardening baseline across runtime, persistence, architecture boundaries, and CI:
+
+1. **Network/security defaults**
+ - Keep request/response body logging in debug only.
+ - Disable cleartext by default and allow it only through debug resource overrides.
+ - Disable app backup to reduce accidental restore of sensitive session-adjacent data.
+
+2. **Database evolution**
+ - Remove destructive fallback.
+ - Require explicit Room migrations (`PledgerDatabaseMigrations`).
+ - Export Room schema JSON for auditability and migration review.
+
+3. **Architecture boundaries**
+ - Move report overview cache usage behind a domain contract (`ReportsOverviewStore`).
+ - Route invoice-scan orchestration through a domain use case (`ProcessInvoiceScanUseCase`)
+ and domain reader contract (`InvoiceTextReader`) instead of direct UI dependency on data
+ extraction classes.
+
+4. **Quality gates**
+ - Enforce `lintDebug` in CI with a baseline so new issues fail while historical debt is tracked.
+ - Add a first `androidTest` smoke check and run instrumented tests in CI.
+
+## Consequences
+
+### Positive
+
+- Reduced risk of leaking sensitive payloads in production logs.
+- More secure default transport policy while keeping local/dev workflows.
+- Safer app upgrades with explicit schema evolution.
+- Better layer isolation for reporting and invoice-scan flows.
+- CI now blocks on new lint regressions and validates a real device startup path.
+
+### Negative
+
+- CI becomes slower due to instrumented tests.
+- Migration ownership is now required for every schema version bump.
+- Some historical lint debt remains until baseline is gradually burned down.
+
+### Follow-up
+
+- Continue extracting remaining large ViewModel responsibilities.
+- Add additional instrumented smoke tests for onboarding, login, and tab navigation.
+- Gradually retire lint baseline entries instead of allowing debt to grow.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 3b9c800..7d75a44 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -24,6 +24,7 @@ This directory contains Architecture Decision Records (ADRs) for the Pledger.io
| [016](016-usability-improvement-program.md) | Usability Improvement Program | Accepted |
| [017](017-deep-links-and-reports.md) | Deep Links and Reports Data | Accepted |
| [018](018-app-localization.md) | App Localization (en / nl / de) | Accepted |
+| [019](019-security-and-quality-hardening.md) | Security and Quality Hardening Baseline | Accepted |
## ADR Format