docs: add architecture docs, AGENTS.md, CLAUDE.md, and copy UI components

- Add docs/01-04 covering data model, interfaces, function TODOs, and implementation plan
- Add AGENTS.md with project conventions, architecture rules, feature workflow, and git policy
- Add CLAUDE.md pointing to AGENTS.md
- Copy UI components and utils from info-krl-android (preference widgets, scrollbars, etc.)
- Delete obsolete UiModule.kt
This commit is contained in:
Achmad Setyabudi Susilo
2026-06-28 15:08:37 +07:00
parent dfca375a9b
commit 3e30423083
50 changed files with 6048 additions and 7 deletions
+175
View File
@@ -0,0 +1,175 @@
# 01 — Data Model
Models are scoped to their feature under `dev.achmad.ledgerr.domain.<feature>.model`. No shared/common package — features import from each other's model package where needed.
---
## Feature: expense
```kotlin
// DateRange.kt
data class DateRange(
val start: LocalDate,
val end: LocalDate
) {
companion object {
fun thisMonth(): DateRange {
val now = LocalDate.now()
return DateRange(now.withDayOfMonth(1), now.withDayOfMonth(now.lengthOfMonth()))
}
fun thisWeek(): DateRange {
val now = LocalDate.now()
return DateRange(now.with(DayOfWeek.MONDAY), now.with(DayOfWeek.SUNDAY))
}
}
}
// Expense.kt
data class Expense(
val id: Long = 0,
val amount: Double, // always positive; represents outflow
val categoryId: Long,
val date: LocalDate,
val note: String? = null,
val recurringExpenseId: Long? = null, // set if auto-generated from a RecurringExpense
val createdAt: Long = System.currentTimeMillis()
)
// ExpenseWithCategory.kt
data class ExpenseWithCategory(
val expense: Expense,
val category: Category // imported from domain.category.model
)
// ExpenseSummary.kt
data class ExpenseSummary(
val totalAmount: Double,
val byCategory: List<Pair<Category, Double>>, // sorted by amount DESC
val period: DateRange
)
```
---
## Feature: category
```kotlin
// Category.kt
data class Category(
val id: Long = 0,
val name: String,
val color: Int, // ARGB
val iconName: String? = null, // Material icon name
val isDefault: Boolean = false // non-deletable; "Other" is always true
)
```
Default categories seeded on first install:
| Name | color (ARGB hex) | isDefault |
|------|------------------|-----------|
| Food & Drink | `0xFFFF9800` (orange) | false |
| Transport | `0xFF2196F3` (blue) | false |
| Housing | `0xFF795548` (brown) | false |
| Health | `0xFFF44336` (red) | false |
| Entertainment | `0xFF9C27B0` (purple) | false |
| Shopping | `0xFFE91E63` (pink) | false |
| Education | `0xFF4CAF50` (green) | false |
| Other | `0xFF9E9E9E` (grey) | **true** |
`Other` is the permanent fallback when a user-defined category is deleted.
---
## Feature: recurring
```kotlin
// RecurringInterval.kt
enum class RecurringInterval {
DAILY, WEEKLY, MONTHLY, YEARLY;
fun advance(from: LocalDate): LocalDate = when (this) {
DAILY -> from.plusDays(1)
WEEKLY -> from.plusWeeks(1)
MONTHLY -> from.plusMonths(1)
YEARLY -> from.plusYears(1)
}
}
// RecurringExpense.kt
data class RecurringExpense(
val id: Long = 0,
val amount: Double,
val categoryId: Long,
val note: String? = null,
val interval: RecurringInterval,
val startDate: LocalDate,
val nextDueDate: LocalDate, // initialized = startDate; advances after each processing
val isActive: Boolean = true
)
// RecurringExpenseWithCategory.kt
data class RecurringExpenseWithCategory(
val recurring: RecurringExpense,
val category: Category
)
```
---
## Feature: bankstatement
Each bank has its own raw model representing a single parsed row from the bank's PDF format. These exist so bank-specific parsing logic produces typed data before converting to the common `PendingImportExpense`.
```kotlin
// PendingImportExpense.kt — common output for the review screen
data class PendingImportExpense(
val amount: Double,
val date: LocalDate,
val description: String, // raw text from PDF
val suggestedCategoryId: Long? = null,
val isSelected: Boolean = true // user can deselect before committing
)
// BRIStatementEntry.kt
data class BRIStatementEntry(
val date: String, // raw string as it appears in PDF
val description: String,
val debit: String?, // raw amount string, null if not a debit
val credit: String?,
val balance: String?
)
// JagoStatementEntry.kt
data class JagoStatementEntry(
val date: String,
val description: String,
val amount: String,
val type: String // e.g. "DEBIT" / "KREDIT"
)
// BNIStatementEntry.kt
data class BNIStatementEntry(
val date: String,
val description: String,
val debit: String?,
val credit: String?,
val balance: String?
)
```
---
## Feature: export
No domain models — uses `Expense`, `ExpenseWithCategory`, and `DateRange` from the expense feature.
---
## Room Entities (data layer only)
Live in `data/local/entity/`. Mirror domain models with Room annotations; dates stored as `Long` (epoch day via `LocalDateConverter`).
- `CategoryEntity`
- `ExpenseEntity`
- `RecurringExpenseEntity`
+185
View File
@@ -0,0 +1,185 @@
# 02 — Interactors
Each feature under `dev.achmad.ledgerr.domain.<feature>.interactor` owns a set of interactor classes. Interactors are use-case classes that can hold logic and depend on whatever they need (DAOs, Android context, etc.). The UI layer depends only on domain — never on data directly.
Naming convention for methods:
- `await(...)` — suspend, returns a single value
- `awaitAll(...)` — suspend, returns a list
- `subscribeOne(...)` — returns `Flow<T?>`
- `subscribeAll(...)` — returns `Flow<List<T>>`
- Filters are parameters, not separate functions
---
## Feature: expense
### `GetExpenses`
```kotlin
class GetExpenses(dao: ExpenseDao, categoryDao: CategoryDao) {
fun subscribeAll(): Flow<List<ExpenseWithCategory>>
fun subscribeByDateRange(range: DateRange): Flow<List<ExpenseWithCategory>>
suspend fun awaitOne(id: Long): ExpenseWithCategory?
suspend fun awaitAll(query: String = "", range: DateRange? = null): List<ExpenseWithCategory>
}
```
### `UpsertExpense`
```kotlin
class UpsertExpense(dao: ExpenseDao) {
suspend fun await(expense: Expense): Long // insert if id=0, update otherwise
}
```
### `InsertExpenses`
```kotlin
class InsertExpenses(dao: ExpenseDao) {
suspend fun awaitAll(expenses: List<Expense>) // bulk insert, single transaction
}
```
### `DeleteExpense`
```kotlin
class DeleteExpense(dao: ExpenseDao) {
suspend fun await(id: Long)
}
```
### `ReassignExpenseCategory`
```kotlin
class ReassignExpenseCategory(dao: ExpenseDao) {
suspend fun await(fromCategoryId: Long, toCategoryId: Long)
}
```
### `GetExpenseSummary`
```kotlin
class GetExpenseSummary(dao: ExpenseDao, categoryDao: CategoryDao) {
suspend fun await(range: DateRange): ExpenseSummary
}
```
---
## Feature: category
### `GetCategories`
```kotlin
class GetCategories(dao: CategoryDao) {
fun subscribeAll(): Flow<List<Category>>
suspend fun awaitOne(id: Long): Category?
suspend fun awaitAll(): List<Category>
suspend fun awaitDefault(): Category // returns the isDefault=true "Other" category
}
```
### `UpsertCategory`
```kotlin
class UpsertCategory(dao: CategoryDao) {
suspend fun await(category: Category): Long
}
```
### `DeleteCategory`
```kotlin
class DeleteCategory(
dao: CategoryDao,
reassignExpenseCategory: ReassignExpenseCategory,
getCategories: GetCategories,
) {
suspend fun await(id: Long)
// internally: reassign orphaned expenses to "Other", then delete
}
```
### `SeedDefaultCategories`
```kotlin
class SeedDefaultCategories(dao: CategoryDao) {
suspend fun await() // no-op if categories already exist
}
```
---
## Feature: recurring
### `GetRecurringExpenses`
```kotlin
class GetRecurringExpenses(dao: RecurringExpenseDao, categoryDao: CategoryDao) {
fun subscribeAll(): Flow<List<RecurringExpenseWithCategory>>
suspend fun awaitOne(id: Long): RecurringExpense?
}
```
### `UpsertRecurringExpense`
```kotlin
class UpsertRecurringExpense(dao: RecurringExpenseDao) {
suspend fun await(recurring: RecurringExpense): Long
}
```
### `DeleteRecurringExpense`
```kotlin
class DeleteRecurringExpense(dao: RecurringExpenseDao) {
suspend fun await(id: Long)
// does NOT delete expense instances already created from this template
}
```
### `ProcessDueRecurringExpenses`
```kotlin
class ProcessDueRecurringExpenses(
recurringDao: RecurringExpenseDao,
expenseDao: ExpenseDao,
) {
suspend fun await(today: LocalDate = LocalDate.now()): List<Expense>
}
```
---
## Feature: bankstatement
### `BankStatementImporter` (interface)
```kotlin
interface BankStatementImporter {
val bankName: String
suspend fun await(pdfUri: Uri): Result<List<PendingImportExpense>>
}
```
### `ImportBRIBankStatement : BankStatementImporter`
```kotlin
class ImportBRIBankStatement(context: Context) : BankStatementImporter {
override val bankName = "BRI"
override suspend fun await(pdfUri: Uri): Result<List<PendingImportExpense>> // stub
}
```
### `ImportJagoBankStatement : BankStatementImporter`
```kotlin
class ImportJagoBankStatement(context: Context) : BankStatementImporter {
override val bankName = "Jago"
override suspend fun await(pdfUri: Uri): Result<List<PendingImportExpense>> // stub
}
```
### `ImportBNIBankStatement : BankStatementImporter`
```kotlin
class ImportBNIBankStatement(context: Context) : BankStatementImporter {
override val bankName = "BNI"
override suspend fun await(pdfUri: Uri): Result<List<PendingImportExpense>> // stub
}
```
`DomainModule` binds all three as `List<BankStatementImporter>` so the UI can show a bank picker.
---
## Feature: export
### `ExportExpensesToCsv`
```kotlin
class ExportExpensesToCsv(expenseDao: ExpenseDao, categoryDao: CategoryDao, context: Context) {
suspend fun await(range: DateRange, outputUri: Uri): Result<Unit>
}
```
+174
View File
@@ -0,0 +1,174 @@
# 03 — Function TODOs
What each interactor method does, inputs, outputs, and edge cases.
---
## expense / GetExpenses
### `subscribeAll(): Flow<List<ExpenseWithCategory>>`
JOIN expenses with categories, ordered by `date DESC`, `createdAt DESC`. Emits on any change to either table.
### `subscribeByDateRange(range: DateRange): Flow<List<ExpenseWithCategory>>`
Same join, filtered: `expense.date >= range.start AND expense.date <= range.end`. Ordered date DESC.
### `awaitOne(id: Long): ExpenseWithCategory?`
Single JOIN lookup by expense id. Returns null if not found.
### `awaitAll(query: String, range: DateRange?): List<ExpenseWithCategory>`
One-shot search. If `query` is blank and `range` is null, returns all expenses (ordered date DESC). If `query` is non-blank, filters: `note LIKE '%query%'` OR `CAST(amount AS TEXT) LIKE '%query%'` OR `category.name LIKE '%query%'`. If `range` is non-null, also filters by date range. Results ordered date DESC.
---
## expense / UpsertExpense
### `await(expense: Expense): Long`
If `expense.id == 0L`: insert and return generated id. Otherwise: update by id and return the existing id. Uses Room `@Upsert`.
---
## expense / InsertExpenses
### `awaitAll(expenses: List<Expense>)`
Bulk insert in a single Room transaction. Used after the user confirms PDF import. `expense.id` must be 0 for all items.
---
## expense / DeleteExpense
### `await(id: Long)`
Deletes expense by primary key. Does not touch any `RecurringExpense` template that generated it — those continue running independently.
---
## expense / ReassignExpenseCategory
### `await(fromCategoryId: Long, toCategoryId: Long)`
Single SQL `UPDATE expenses SET categoryId = :toCategoryId WHERE categoryId = :fromCategoryId`. Used by `DeleteCategory` before removing a category.
---
## expense / GetExpenseSummary
### `await(range: DateRange): ExpenseSummary`
1. Fetch all expenses with category in range (one-shot, uses DAO directly).
2. Sum all amounts → `totalAmount`.
3. Group by category, sum per group, sort by amount DESC → `byCategory`.
4. Return `ExpenseSummary(totalAmount, byCategory, range)`.
---
## category / GetCategories
### `subscribeAll(): Flow<List<Category>>`
All categories ordered by name ASC, emits on change.
### `awaitOne(id: Long): Category?`
Nullable lookup by primary key.
### `awaitAll(): List<Category>`
One-shot list, ordered by name ASC.
### `awaitDefault(): Category`
Returns the single category where `isDefault = true`. Throws `IllegalStateException` if not found (seeding hasn't run — should not happen in practice).
---
## category / UpsertCategory
### `await(category: Category): Long`
Insert if `id == 0`, update otherwise. Returns the id. Does not allow changing `isDefault` — if the incoming category has `isDefault = true` but the existing DB record has `isDefault = false`, the DB value wins (impl reads existing `isDefault` before update). In practice, only the seeder sets `isDefault = true`.
---
## category / DeleteCategory
### `await(id: Long)`
1. Guard: fetch category by id. If `isDefault = true`, throw `IllegalArgumentException("Cannot delete a default category")`.
2. Find the fallback: call `getCategories.awaitDefault()` to get the "Other" category id.
3. Call `reassignExpenseCategory.await(id, fallbackId)` to move orphaned expenses.
4. Delete the category from the DB.
Steps 3 and 4 run sequentially in the interactor (not a DB transaction). If step 4 fails, expenses are already reassigned to Other — acceptable, retry is safe.
---
## category / SeedDefaultCategories
### `await()`
Checks count of categories in DB. If count > 0, no-op. Otherwise inserts the 8 default categories (see `01-data-model.md`). Called from `MainApplication.onCreate` after `startKoin` completes, using a `CoroutineScope(Dispatchers.IO)`.
---
## recurring / GetRecurringExpenses
### `subscribeAll(): Flow<List<RecurringExpenseWithCategory>>`
All recurring expenses (active and inactive) joined with category, ordered by `nextDueDate ASC`. Emits on change.
### `awaitOne(id: Long): RecurringExpense?`
Nullable lookup by primary key.
---
## recurring / UpsertRecurringExpense
### `await(recurring: RecurringExpense): Long`
Insert if `id == 0`, update otherwise. Caller must set `nextDueDate = startDate` on initial insert.
---
## recurring / DeleteRecurringExpense
### `await(id: Long)`
Deletes the recurring template by id. Expense instances previously created from this template retain their `recurringExpenseId` value — they become historical records and are not deleted.
---
## recurring / ProcessDueRecurringExpenses
### `await(today: LocalDate): List<Expense>`
1. Query recurring expenses where `isActive = true AND nextDueDate <= today`.
2. For each due item:
a. Create `Expense(amount, categoryId, note from template, date = nextDueDate, recurringExpenseId = template.id)`.
b. Insert the expense.
c. Advance: `newNextDueDate = interval.advance(current nextDueDate)`.
d. Update the recurring template with `nextDueDate = newNextDueDate`.
3. Return the list of created expenses so the caller can show a banner.
Edge case — app not opened for multiple intervals: advances `nextDueDate` by one interval per call. The next app open will process it again if still overdue. This avoids flooding the expense list with back-filled entries.
---
## bankstatement / BankStatementImporter
### `val bankName: String`
Display name shown in the bank picker (e.g. `"BRI"`, `"Jago"`, `"BNI"`).
### `await(pdfUri: Uri): Result<List<PendingImportExpense>>`
Contract for all implementations:
1. Open PDF via `ContentResolver.openInputStream(pdfUri)`.
2. Extract text with PDFBox-Android (`PDDocument` + `PDFTextStripper`), page by page.
3. Parse bank-specific format into raw entry models (e.g. `BRIStatementEntry`).
4. Filter to debit-only rows (skip credits/income).
5. Map raw entries to `PendingImportExpense` — parse Indonesian number format (`1.500.000,00`), parse date strings to `LocalDate`.
6. Return `Result.success(list)` or `Result.failure(exception)`.
**`ImportBRIBankStatement.await`** — stub: `Result.failure(NotImplementedError("BRI import not yet implemented"))`.
**`ImportJagoBankStatement.await`** — stub: same.
**`ImportBNIBankStatement.await`** — stub: same.
---
## export / ExportExpensesToCsv
### `await(range: DateRange, outputUri: Uri): Result<Unit>`
1. Fetch all `ExpenseWithCategory` in range from DAO (one-shot).
2. Open `OutputStream` via `context.contentResolver.openOutputStream(outputUri)`.
3. Wrap with Okio `BufferedSink`.
4. Write UTF-8 BOM (`0xEF, 0xBB, 0xBF`) for Excel compatibility.
5. Write header: `Date,Category,Amount,Note`.
6. For each expense: `date` in ISO 8601 (`yyyy-MM-dd`), then `category.name`, `amount`, `"note"` (double-quote the note to handle embedded commas).
7. Close sink.
8. Return `Result.success(Unit)` or `Result.failure(exception)`.
+412
View File
@@ -0,0 +1,412 @@
# 04 — Implementation Plan
---
## Dependencies to Add
In `gradle/libs.versions.toml`:
```toml
[versions]
room = "2.7.1"
pdfbox_android = "2.0.27.0"
[libraries]
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
pdfbox-android = { group = "com.tom-roush", name = "pdfbox-android", version.ref = "pdfbox_android" }
```
In `app/build.gradle.kts`:
```kotlin
ksp(libs.room.compiler)
implementation(libs.room.runtime)
implementation(libs.room.ktx)
implementation(libs.pdfbox.android)
```
**No chart library** — Canvas-based (`drawArc` for pie, `drawRect` for bars).
**No manifest permissions** — SAF handles file access.
---
## Package Structure
```
dev.achmad.ledgerr/
├── core/ (existing)
│ ├── network/
│ └── preference/
├── data/
│ └── local/
│ ├── AppDatabase.kt
│ ├── converter/
│ │ └── LocalDateConverter.kt - LocalDate <-> Long (epoch day)
│ ├── dao/
│ │ ├── CategoryDao.kt
│ │ ├── ExpenseDao.kt
│ │ └── RecurringExpenseDao.kt
│ ├── entity/
│ │ ├── CategoryEntity.kt
│ │ ├── ExpenseEntity.kt
│ │ └── RecurringExpenseEntity.kt
│ └── mapper/
│ ├── CategoryMapper.kt
│ ├── ExpenseMapper.kt
│ └── RecurringExpenseMapper.kt
├── di/
│ ├── CoreModule.kt - provides PreferenceStore as AndroidPreferenceStore
│ ├── DataModule.kt - DB, DAOs
│ ├── DomainModule.kt - all interactors
│ ├── PreferenceModule.kt - preference class singletons
│ └── util/
│ └── KoinExtensions.kt (existing)
├── domain/
│ ├── preference/
│ │ ├── AppPreference.kt - app-wide: theme
│ │ └── ExpensePreference.kt - expense display: default date range filter
│ │
│ ├── expense/
│ │ ├── model/
│ │ │ ├── DateRange.kt
│ │ │ ├── Expense.kt
│ │ │ ├── ExpenseWithCategory.kt
│ │ │ └── ExpenseSummary.kt
│ │ └── interactor/
│ │ ├── GetExpenses.kt
│ │ ├── UpsertExpense.kt
│ │ ├── InsertExpenses.kt
│ │ ├── DeleteExpense.kt
│ │ ├── ReassignExpenseCategory.kt
│ │ └── GetExpenseSummary.kt
│ │
│ ├── category/
│ │ ├── model/
│ │ │ └── Category.kt
│ │ └── interactor/
│ │ ├── GetCategories.kt
│ │ ├── UpsertCategory.kt
│ │ ├── DeleteCategory.kt
│ │ └── SeedDefaultCategories.kt
│ │
│ ├── recurring/
│ │ ├── model/
│ │ │ ├── RecurringExpense.kt
│ │ │ ├── RecurringExpenseWithCategory.kt
│ │ │ └── RecurringInterval.kt
│ │ └── interactor/
│ │ ├── GetRecurringExpenses.kt
│ │ ├── UpsertRecurringExpense.kt
│ │ ├── DeleteRecurringExpense.kt
│ │ └── ProcessDueRecurringExpenses.kt
│ │
│ ├── bankstatement/
│ │ ├── model/
│ │ │ ├── PendingImportExpense.kt
│ │ │ ├── BRIStatementEntry.kt
│ │ │ ├── JagoStatementEntry.kt
│ │ │ └── BNIStatementEntry.kt
│ │ └── interactor/
│ │ ├── BankStatementImporter.kt - interface
│ │ ├── ImportBRIBankStatement.kt - stub
│ │ ├── ImportJagoBankStatement.kt - stub
│ │ └── ImportBNIBankStatement.kt - stub
│ │
│ └── export/
│ └── interactor/
│ └── ExportExpensesToCsv.kt
└── ui/
├── base/
│ ├── MainApplication.kt
│ └── MainActivity.kt (existing)
├── components/ (copied from info-krl-android, see below)
│ ├── AppBar.kt
│ ├── CardSection.kt
│ ├── FilterChipGroup.kt
│ ├── HelpCard.kt
│ ├── LabeledCheckbox.kt
│ ├── LabeledRadioButton.kt
│ ├── LazyGridScrollbar.kt
│ ├── LazyListScrollbar.kt
│ ├── LinkIcon.kt
│ ├── Pill.kt
│ ├── ResultScreen.kt
│ ├── ScrollbarLazyColumn.kt
│ ├── ScrollbarLazyGrid.kt
│ ├── SpotlightOverlay.kt
│ ├── TabText.kt
│ └── preference/ (copied from info-krl-android)
│ ├── Preference.kt
│ ├── PreferenceItem.kt
│ ├── PreferenceScreen.kt
│ └── widget/
│ ├── AlertDialogPreferenceWidget.kt
│ ├── BasePreferenceWidget.kt
│ ├── BasicMultiSelectListPreferenceWidget.kt
│ ├── CheckPreferenceWidget.kt
│ ├── EditTextPreferenceWidget.kt
│ ├── InfoWidget.kt
│ ├── ListPreferenceWidget.kt
│ ├── ListSearchPreferenceWidget.kt
│ ├── MultiSelectListPreferenceWidget.kt
│ ├── PermissionPreferenceWidget.kt
│ ├── PreferenceGroupHeader.kt
│ ├── SwitchPreferenceWidget.kt
│ ├── TextPreferenceWidget.kt
│ └── TriStateListDialog.kt
├── screens/
│ ├── home/
│ │ └── HomeScreen.kt - dashboard + recent expenses
│ ├── expenses/
│ │ └── ExpenseListScreen.kt - 2-tab screen (Expenses | Recurring)
│ ├── add_edit_expense/
│ │ └── AddEditExpenseScreen.kt - shared add & edit
│ ├── category/
│ │ └── CategoryScreen.kt
│ ├── import_bank_statement/
│ │ └── ImportBankStatementScreen.kt - 1 Voyager Screen, 2 internal composables:
│ │ ImportBankStatementPickerContent (bank picker + SAF trigger + processing)
│ │ ImportBankStatementConfirmationContent (review/edit/confirm)
│ └── settings/
│ └── SettingsScreen.kt - uses PreferenceScreen component
├── util/ (copied from info-krl-android)
│ ├── ColorUtil.kt
│ ├── ModifierUtil.kt
│ ├── NavigatorUtil.kt
│ ├── PreferenceUtil.kt
│ ├── StringUtil.kt
│ ├── TextFieldUtil.kt
│ ├── UiImage.kt
│ └── UiText.kt
└── theme/ (existing)
├── Theme.kt
└── Type.kt
```
### ScreenModel pattern
ScreenModels are **not** registered in Koin. Interactors and preference classes are injected as constructor default parameters using `inject()` from `KoinExtensions`:
```kotlin
class HomeScreenModel(
private val getExpenses: GetExpenses = inject(),
private val getExpenseSummary: GetExpenseSummary = inject(),
private val processRecurring: ProcessDueRecurringExpenses = inject(),
private val appPreference: AppPreference = inject(),
) : ScreenModel { ... }
```
In `Screen.Content()`:
```kotlin
val screenModel = rememberScreenModel { HomeScreenModel() }
```
---
## Preference Classes
### `domain/preference/AppPreference.kt`
```kotlin
class AppPreference(private val store: PreferenceStore) {
fun appTheme() = store.getEnum("app_theme", defaultValue = AppTheme.SYSTEM)
}
enum class AppTheme { LIGHT, DARK, SYSTEM }
```
### `domain/preference/ExpensePreference.kt`
```kotlin
class ExpensePreference(private val store: PreferenceStore) {
fun defaultDateRange() = store.getEnum("expense_default_date_range", defaultValue = DateRangeOption.THIS_MONTH)
}
enum class DateRangeOption { THIS_WEEK, THIS_MONTH }
```
---
## Navigation
Pure stack navigation using Voyager `Navigator`. No `TabNavigator` at the root level.
```
HomeScreen (root)
├── FAB (expanded) ─────────────────────────── "Manual" → AddEditExpenseScreen
│ "Import Bank Statement" → ImportBankStatementScreen
├── Export icon ────────────────────────────── date range dialog → SAF CreateDocument → writes CSV
├── "See all" / "View expenses" button ──────── → ExpenseListScreen
│ ├── Tab 1: Expenses list (search + filter)
│ │ └── FAB (expanded) → "Manual" → AddEditExpenseScreen
│ │ → "Import" → ImportBankStatementScreen
│ └── Tab 2: Recurring list
├── "Manage Categories" button (dashboard) ──── → CategoryScreen
└── Settings icon ──────────────────────────── → SettingsScreen
AddEditExpenseScreen
└── "Manage Categories" button ───────────────── → CategoryScreen
ImportBankStatementScreen (1 Voyager Screen, 1 ScreenModel, 2 composable functions)
├── ImportBankStatementPickerContent — rendered when state is BankPicker or Processing
│ • list of banks from List<BankStatementImporter>
│ • selecting a bank opens SAF OpenDocument picker (PDF)
│ • loading overlay on top while state is Processing
└── ImportBankStatementConfirmationContent — rendered when state is Confirmation
• list of PendingImportExpense
• toggle selection per row
• edit amount / date / description / category inline
• "Import X items" → InsertExpenses.awaitAll() → navigator.pop()
```
Notes:
- **ExpenseListScreen tabs** are plain Compose `TabRow` / `HorizontalPager` — not Voyager tabs.
- **FAB expansion** on both `HomeScreen` and `ExpenseListScreen`: tapping reveals two sub-actions; second tap or outside tap collapses it.
- **Export** — tapping the export icon shows a date range picker dialog (start + end date); after confirming the range the SAF `CreateDocument` picker opens. The ScreenModel writes the CSV (ISO 8601 dates, UTF-8 BOM) when the URI comes back. Export icon lives in `HomeScreen` top bar, `ExpenseListScreen` top bar, and as a `TextPreference` in `SettingsScreen`.
- **Import** goes through `ImportBankStatementScreen` (one Voyager Screen, one ScreenModel). The ScreenModel owns a `StateFlow<ImportState>` that switches between two internal composables: `ImportBankStatementPickerContent` (BankPicker + Processing overlay) and `ImportBankStatementConfirmationContent` (review, edit, confirm). On confirmation the ScreenModel calls `InsertExpenses.awaitAll()` then pops.
- **CategoryScreen** reachable from the "Manage Categories" button on `HomeScreen` dashboard and from inside `AddEditExpenseScreen`.
- **SettingsScreen** uses `PreferenceScreen` component (same pattern as `MoreTab` in info-krl-android). Exposes: theme selector (`ListPreference`), export action (`TextPreference`), clear data (`AlertDialogPreference`).
---
## DI Wiring
### CoreModule
```kotlin
val coreModule = module {
single {
get<Context>().getSharedPreferences("ledgerr_prefs", Context.MODE_PRIVATE)
}
single<PreferenceStore> { AndroidPreferenceStore(get()) }
}
```
### DataModule
```kotlin
val dataModule = module {
single {
Room.databaseBuilder(androidApplication(), AppDatabase::class.java, "ledgerr.db").build()
}
single { get<AppDatabase>().categoryDao() }
single { get<AppDatabase>().expenseDao() }
single { get<AppDatabase>().recurringExpenseDao() }
}
```
### DomainModule
```kotlin
val domainModule = module {
// expense
factory { GetExpenses(get(), get()) }
factory { UpsertExpense(get()) }
factory { InsertExpenses(get()) }
factory { DeleteExpense(get()) }
factory { ReassignExpenseCategory(get()) }
factory { GetExpenseSummary(get(), get()) }
// category
factory { GetCategories(get()) }
factory { UpsertCategory(get()) }
factory { DeleteCategory(get(), get(), get()) }
factory { SeedDefaultCategories(get()) }
// recurring
factory { GetRecurringExpenses(get(), get()) }
factory { UpsertRecurringExpense(get()) }
factory { DeleteRecurringExpense(get()) }
factory { ProcessDueRecurringExpenses(get(), get()) }
// bankstatement
factory<BankStatementImporter>(named("bri")) { ImportBRIBankStatement(androidContext()) }
factory<BankStatementImporter>(named("jago")) { ImportJagoBankStatement(androidContext()) }
factory<BankStatementImporter>(named("bni")) { ImportBNIBankStatement(androidContext()) }
factory<List<BankStatementImporter>> {
listOf(get(named("bri")), get(named("jago")), get(named("bni")))
}
// export
factory { ExportExpensesToCsv(get(), get(), androidContext()) }
}
```
### PreferenceModule
```kotlin
val preferenceModule = module {
single { AppPreference(get()) }
single { ExpensePreference(get()) }
}
```
---
## Key Implementation Details
### MainApplication
```kotlin
class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
PDFBoxResourceLoader.init(this)
startKoin {
androidLogger()
androidContext(this@MainApplication)
modules(coreModule, dataModule, domainModule, preferenceModule)
}
CoroutineScope(Dispatchers.IO).launch {
inject<SeedDefaultCategories>().await()
}
}
}
```
Register in `AndroidManifest.xml` as `android:name=".ui.base.MainApplication"`.
### AppDatabase seeding
After `startKoin` completes in `MainApplication.onCreate`, call `SeedDefaultCategories` via a `CoroutineScope(Dispatchers.IO)`:
```kotlin
CoroutineScope(Dispatchers.IO).launch {
inject<SeedDefaultCategories>().await()
}
```
`SeedDefaultCategories.await()` is a no-op if categories already exist, so this is safe to call on every launch. No Room Callback is used for seeding.
### Recurring processor trigger
`HomeScreen`'s ScreenModel calls `ProcessDueRecurringExpenses` in `init {}`. If any are added, a dismissable banner appears on the dashboard.
### SAF file pickers
- PDF import: `ActivityResultContracts.OpenDocument(arrayOf("application/pdf"))`
- CSV export: `ActivityResultContracts.CreateDocument("text/csv")`
Launchers registered in the Screen composable; URIs passed to the ScreenModel.
---
## Build Order
1. Domain models (all features)
2. Domain preference classes
3. Data: entities, DAOs, `LocalDateConverter`, `AppDatabase`
4. Mappers
5. Domain interactors (category → expense → recurring → bank stubs → export)
6. DI modules
7. `MainApplication` + manifest
8. Screens: `HomeScreen``ExpenseListScreen``AddEditExpenseScreen``CategoryScreen``ImportBankStatementScreen``SettingsScreen`
9. UI components extracted as needed during screen work