- 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)
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
HomeScreenandExpenseListScreen: tapping reveals sub-actions; second tap or outside tap collapses it. OnExpenseListScreenthe 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
CreateDocumentpicker opens. The ScreenModel writes the CSV (ISO 8601 dates, UTF-8 BOM) when the URI comes back. Export icon lives inHomeScreentop bar,ExpenseListScreentop bar, and as aTextPreferenceinSettingsScreen. The flow is identical across the three call sites, so the date-range dialog andCreateDocumentlauncher 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 aStateFlow<ImportState>that switches between two internal composables:ImportBankStatementPickerContent(BankPicker + Processing overlay) andImportBankStatementConfirmationContent(review, edit, confirm). On confirmation the ScreenModel callsInsertExpenses.awaitAll()then pops. - CategoryScreen reachable from the "Manage Categories" button on
HomeScreendashboard and from insideAddEditExpenseScreen. - SettingsScreen uses
PreferenceScreencomponent (same pattern asMoreTabin info-krl-android). Exposes: theme selector (ListPreference), export action (TextPreference), clear data (AlertDialogPreference— callsClearAllData.await()thenSeedDefaultCategories.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
- Domain models (all features, including
datafeature placeholder if needed) - Domain preference classes
- Data: entities, DAOs,
LocalDateConverter,AppDatabase - Mappers
- Domain interactors (category → expense → recurring → bank stubs → export → data)
- DI modules
MainApplication+ manifest- Screens:
HomeScreen→ExpenseListScreen(with tab-aware FAB) →AddEditExpenseScreen→AddEditRecurringScreen→CategoryScreen→ImportBankStatementScreen→SettingsScreen - Shared UI components extracted as needed during screen work (including
ExportActionhelper)