51c54749cb
- AGENTS.md: add No-implementation rule and Issue-driven workflow section - docs/01: rename default fallback category from 'Other' to 'Uncategorized' - docs/02, 03: update 'Other' references to 'Uncategorized' in delete/seed/default flows - docs/04: replace Canvas charts with Vico 2.x dep, add data feature folder, add AddEditRecurringScreen, document tab-aware FAB and shared ExportAction helper
436 lines
18 KiB
Markdown
436 lines
18 KiB
Markdown
# 04 — Implementation Plan
|
|
|
|
---
|
|
|
|
## Dependencies to Add
|
|
|
|
In `gradle/libs.versions.toml`:
|
|
|
|
```toml
|
|
[versions]
|
|
room = "2.7.1"
|
|
pdfbox_android = "2.0.27.0"
|
|
vico = "2.0.0" # Compose-native chart library (Apache 2.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" }
|
|
vico-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico" }
|
|
vico-compose-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico" }
|
|
vico-core = { group = "com.patrykandpatrick.vico", name = "core", version.ref = "vico" }
|
|
```
|
|
|
|
In `app/build.gradle.kts`:
|
|
|
|
```kotlin
|
|
ksp(libs.room.compiler)
|
|
implementation(libs.room.runtime)
|
|
implementation(libs.room.ktx)
|
|
implementation(libs.pdfbox.android)
|
|
implementation(libs.vico.compose)
|
|
implementation(libs.vico.compose.m3)
|
|
implementation(libs.vico.core)
|
|
```
|
|
|
|
**Charts**: Vico 2.x Compose-native library. `HomeScreen` dashboard renders a pie chart from `ExpenseSummary.byCategory`. No custom Canvas drawing.
|
|
|
|
**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
|
|
│
|
|
│ └── data/
|
|
│ └── interactor/
|
|
│ └── ClearAllData.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
|
|
│ ├── add_edit_recurring/
|
|
│ │ └── AddEditRecurringScreen.kt - shared add & edit for recurring templates
|
|
│ ├── 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, tab-aware) → "Manual" → AddEditExpenseScreen
|
|
│ │ → "Import Bank Statement" → ImportBankStatementScreen
|
|
│ └── Tab 2: Recurring list
|
|
│ └── FAB (expanded, tab-aware) → "Add Recurring" → AddEditRecurringScreen
|
|
├── "Manage Categories" button (dashboard) ──── → CategoryScreen
|
|
└── Settings icon ──────────────────────────── → SettingsScreen
|
|
|
|
AddEditExpenseScreen
|
|
└── "Manage Categories" button ───────────────── → CategoryScreen
|
|
|
|
AddEditRecurringScreen
|
|
(no secondary navigation)
|
|
|
|
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()
|
|
```
|
|
|
|
**FAB changes per tab.** `ExpenseListScreen` has a single FAB that observes the currently-selected tab and changes its label and sub-action list accordingly. Expenses tab mirrors the HomeScreen FAB (Manual + Import). Recurring tab exposes only "Add Recurring" → `AddEditRecurringScreen`. The expanded mini-FAB UI (sub-action buttons) is the same component, just with a different actions list driven by tab state.
|
|
|
|
Notes:
|
|
- **ExpenseListScreen tabs** are plain Compose `TabRow` / `HorizontalPager` — not Voyager tabs.
|
|
- **FAB expansion** on both `HomeScreen` and `ExpenseListScreen`: tapping reveals sub-actions; second tap or outside tap collapses it. On `ExpenseListScreen` the FAB is **tab-aware** — the list of sub-actions changes based on the currently-selected tab.
|
|
- **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`. The flow is identical across the three call sites, so the date-range dialog and `CreateDocument` launcher are factored into a shared composable (`ui/components/ExportAction.kt`) that takes the active ScreenModel and a date range to export.
|
|
- **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` — calls `ClearAllData.await()` then `SeedDefaultCategories.await()` so the app re-seeds the 8 default categories).
|
|
|
|
---
|
|
|
|
## 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()) }
|
|
|
|
// data
|
|
factory { ClearAllData(get()) }
|
|
}
|
|
```
|
|
|
|
### 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, including `data` feature placeholder if needed)
|
|
2. Domain preference classes
|
|
3. Data: entities, DAOs, `LocalDateConverter`, `AppDatabase`
|
|
4. Mappers
|
|
5. Domain interactors (category → expense → recurring → bank stubs → export → data)
|
|
6. DI modules
|
|
7. `MainApplication` + manifest
|
|
8. Screens: `HomeScreen` → `ExpenseListScreen` (with tab-aware FAB) → `AddEditExpenseScreen` → `AddEditRecurringScreen` → `CategoryScreen` → `ImportBankStatementScreen` → `SettingsScreen`
|
|
9. Shared UI components extracted as needed during screen work (including `ExportAction` helper)
|