# 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 Vico `ColumnCartesianLayer` from `ExpenseSummary.byCategory`, with a small category legend (`Row { colored swatch; category name; amount }`) below the chart that carries the per-category color. Vico 2.0.0's `cartesian` package only exposes `Line` / `Column` / `Candlestick` layers (no pie), so the dashboard uses a column chart with the legend. 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 │ • 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` 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().getSharedPreferences("ledgerr_prefs", Context.MODE_PRIVATE) } single { AndroidPreferenceStore(get()) } } ``` ### DataModule ```kotlin val dataModule = module { single { Room.databaseBuilder(androidApplication(), AppDatabase::class.java, "ledgerr.db").build() } single { get().categoryDao() } single { get().expenseDao() } single { get().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(named("bri")) { ImportBRIBankStatement(androidContext()) } factory(named("jago")) { ImportJagoBankStatement(androidContext()) } factory(named("bni")) { ImportBNIBankStatement(androidContext()) } factory> { 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().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().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)