Files
ledgerr/docs/04-implementation-plan.md
T
Achmad Setyabudi Susilo 3ddfaa0a22 fix(#5): address PR review — share ExpandedFab, inject GetExpenseSummary, column-chart spec
- Add ui/components/ExpandedFab.kt with ExpandedFab + MiniFab helpers; HomeScreen and ExpenseListScreen both consume it, the tab-collapsing LaunchedEffect in ExpenseListScreen is hoisted to its Content()
- Inject GetExpenseSummary in HomeScreenModel; drive summary via dateRange.flatMapLatest { getExpenseSummary.await(it) } (fixes the period-filter total-card flicker) and drop the inline combine(expenses, dateRange) recomputation
- Hoist isFabExpanded out of HomeScreenModel into HomeScreen.Content() so the FAB state is local to the composable
- Convert HomeScreenModel.exportToCsv from a callback to a suspend fun returning Result<Unit>; the screen does the snackbar dispatch on the coroutineScope
- Consolidate DateRangeOption label mapping to a single DateRangeOption.labelRes() / .labelText() pair (one source of truth)
- Rename FAB string keys to shared fab_manual / fab_import and drop the home_fab_* duplicates
- Update docs/04-implementation-plan.md and .opencode/agent/implementor.md Charts sections to reflect the Vico 2.0.0 column-chart-with-legend substitution (Vico 2.0.0 has no pie layer)
2026-06-28 20:25:53 +07:00

18 KiB

04 — Implementation Plan


Dependencies to Add

In gradle/libs.versions.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:

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:

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():

val screenModel = rememberScreenModel { HomeScreenModel() }

Preference Classes

domain/preference/AppPreference.kt

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

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

val coreModule = module {
    single {
        get<Context>().getSharedPreferences("ledgerr_prefs", Context.MODE_PRIVATE)
    }
    single<PreferenceStore> { AndroidPreferenceStore(get()) }
}

DataModule

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

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

val preferenceModule = module {
    single { AppPreference(get()) }
    single { ExpensePreference(get()) }
}

Key Implementation Details

MainApplication

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):

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: HomeScreenExpenseListScreen (with tab-aware FAB) → AddEditExpenseScreenAddEditRecurringScreenCategoryScreenImportBankStatementScreenSettingsScreen
  9. Shared UI components extracted as needed during screen work (including ExportAction helper)