Implement HomeScreen with Vico dashboard (#5) #20
Reference in New Issue
Block a user
Delete Branch "feat/5-implement-homescreen-with-vico-dashboard"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
HomeScreenModelwith state flows forexpenses,summary,recurringBanner, andselectedDateRange; constructor injectsGetExpenses,GetExpenseSummary,ProcessDueRecurringExpenses,ExportExpensesToCsv, andExpensePreferenceHomeScreenstub with a Material 3 dashboard: AppBar (Export + Settings), primary-container total card, period filter chips, VicoColumnCartesianLayerchart with a per-category legend, Manage Categories / See all actions, Recent section, empty state, and an expanded FAB exposing Manual + Import sub-actionshome_recurring_banner<plurals>resource for singular/plural banner copyui/components/ExpandedFab.kt(ExpandedFab+MiniFab);HomeScreenandExpenseListScreenboth consume it, the tab-collapsingLaunchedEffectinExpenseListScreenis hoisted to itsContent()Test plan
SettingsScreen; Manage Categories pushesCategoryScreen; See all pushesExpenseListScreenExportActioncomposable (date-range dialog + SAFCreateDocument); on failure a Snackbar is shownProcessDueRecurringExpenses.await()runs onDispatchers.IO; if it produces expenses, a dismissable banner appears with singular/plural copy fromR.plurals.home_recurring_banner$0.00between emissions (summary is now driven bygetExpenseSummary.awaitviadateRange.flatMapLatest, not bycombine(expenses, dateRange))ExpenseListScreenswitching tabs collapses the FAB; both screens use the sameExpandedFab/MiniFabfromui/components/./gradlew assembleDebugsucceedsSpec change (addresses PR review)
Vico 2.0.0's
cartesianpackage only exposesLine/Column/Candlesticklayers — there is no pie chart. The original issue spec called for a Vico pie chart, but the dependency pinned ingradle/libs.versions.toml(vico = "2.0.0") doesn't provide one. To keep the "use Vico, no Canvas" architecture rule and the "no chart library other than Vico" rule, the dashboard renders a VicoColumnCartesianLayer(one bar per category) with a per-category legend below the chart. The spec files have been updated to match:docs/04-implementation-plan.md— "Charts" section now describes a column chart with a legend.opencode/agent/implementor.md— "Charts" section and the Vico dependency table updatedgetRecurringExpenses/appPreference;getExpenseSummaryandexportExpensesToCsvinjected), the column-chart rendering, and the hoisted FAB stateCloses #5
Verdict: has blocking concerns
The HomeScreen dashboard, the Vico integration, the recurring banner, the export wiring, the ScreenModel structure, and the new strings are all in line with the spec/issue at a high level. The
rememberScreenModel+inject()pattern is used correctly,Dispatchers.IOis moved off the main thread for the recurring processor and the export, and theExportActionshared composable is used (since #7 is merged). The acceptance test plan is sensible.That said, three concerns should be resolved before merge — they are spec deviations, not style nits:
docs/04-implementation-plan.md,.opencode/agent/implementor.md"Architecture rules / Charts") call for a Vico pie chart. Vico 2.0.0 genuinely doesn't ship a public pie layer (onlyLine/Column/Candlestickin thecartesianpackage), so this deviation is forced by the dependency — but the spec needs to be updated to match, or a different library / approach is needed.GetExpenseSummaryis not injected, and the summary is built inline in the ScreenModel. The issue's constructor signature explicitly requires it.ExpandedFab+MiniFabcomposables inHomeScreen.ktare structural duplicates ofTabAwareFab+MiniFabinExpenseListScreen.kt. The implementation plan says the expansion/collapse UI is shared — both screens should consume the same component fromui/components/.There are also a couple of nits/suggestions (export callback style, period-filter flicker, label-mapping duplication, FAB string duplication). Details inline.
I'm submitting this as
COMMENTper the same-account Gitea constraint (you decide when to merge).Closes #5 once the above is resolved.
@@ -16,0 +269,4 @@amount: Double,period: DateRangeOption,) {val periodLabel = stringResource(Suggestion: The
DateRangeOption → string-resource-idmapping is duplicated three times in this file:TotalCard(lines 272-277),PeriodFilter(line 307 callinglabel(it)), and the file-levellabel(option: DateRangeOption)helper (lines 314-319). Consolidate to a single source of truth — e.g. aprivate fun DateRangeOption.labelRes(): Intand a@Composable private fun DateRangeOption.labelText(): String = stringResource(labelRes())— and call it from all three sites. Cosmetic, but thewhenis exhaustive on a sealed enum so a future enum value (e.g.LAST_MONTH,CUSTOM) will only need updating in one place.@@ -16,0 +320,4 @@@OptIn(ExperimentalMaterial3Api::class)@Composableprivate fun CategoryChartCard(summary: ExpenseSummary) {Blocking: The spec is a pie chart, not a column chart. The issue's acceptance criteria and
.opencode/agent/implementor.md"Architecture rules / Charts" both call for a Vico pie chart ofsummary.byCategory;docs/04-implementation-plan.mdsays the same. You're rendering aColumnCartesianLayer(line 350) plus a hand-rolledCategoryLegend(lines 365-391) below it. Vico 2.0.0'scartesianpackage only exposesLine/Column/Candlesticklayers (no pie), so the spec was written for an API that 2.0.0 doesn't provide. Two acceptable resolutions — pick one and document it in the PR body before merge:docs/04-implementation-plan.md, and.opencode/agent/implementor.md"Charts" section to say "column chart with a legend below it" so the spec matches the implementation.Canvas(rejected by the "do not draw charts with Canvas — use Vico" rule). So option 1 is the only one consistent with the architecture rules.The PR description's parenthetical note ("Vico 2.0.0 has no pie chart; column chart with legend is the substitute, scoped to the existing Vico 2.0.0 dependency") is a good start but it's not enough — the spec files need to match.
@@ -16,0 +474,4 @@}@Composableprivate fun ExpandedFab(Blocking: The
MiniFab(lines 523-547) and the expand/collapse host inExpandedFab(lines 477-521) are essentially copy-pasted fromapp/src/main/java/dev/achmad/ledgerr/ui/screens/expenses/ExpenseListScreen.kt— seeMiniFabat lines 484-508 andTabAwareFabat lines 418-481. The implementations are byte-for-byte equivalent (Row(verticalAlignment = CenterVertically) { Surface { Text } SmallFloatingActionButton { Icon } }; the sameAnimatedVisibility(visible = expanded, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically())host; the sameIcons.Outlined.Add/Icons.Outlined.Closetoggle icon)..opencode/agent/implementor.md"Architecture rules / Navigation" says: "The expansion/collapse UI is shared; only the actions list is tab-driven." The right fix is to extract the shared parts toui/components/ExpandedFab.kt(e.g.fun ExpandedFab(expanded: Boolean, onToggle: () -> Unit, actions: @Composable ColumnScope.() -> Unit)plus aMiniFabhelper) and haveHomeScreenandExpenseListScreenboth consume it —ExpenseListScreenpasses its tab-conditionalMiniFablist as theactionsslot,HomeScreenpasses the static two-action list. TheLaunchedEffect(selectedTab) { expanded = false }inTabAwareFabcan be hoisted to theExpenseListScreencall site.This is also where the third copy of the same UI will go if someone else wires it up — fix it before merge.
@@ -0,0 +28,4 @@import kotlinx.coroutines.withContext@OptIn(ExperimentalCoroutinesApi::class)class HomeScreenModel(Blocking: The issue's constructor example explicitly lists
getExpenseSummary: GetExpenseSummary = inject(), but this ScreenModel has no such field and thesummaryflow is computed inline (lines 60-79) by re-implementing exactly the logic thatGetExpenseSummary.await(range)performs perdocs/03-function-todos.md(sum amounts, group by category, sort DESC, buildExpenseSummary). The inline result is structurally identical to the interactor's output, so this is a spec deviation, not a bug.Pick one:
GetExpenseSummaryand call it from asuspendhelper in the ScreenModel (e.g.private suspend fun computeSummary(range: DateRange): ExpenseSummary? = getExpenseSummary.await(range).takeIf { it.byCategory.isNotEmpty() }), then drivesummaryfrom adateRange.flatMapLatest { ... }instead ofcombine(expenses, dateRange). This also fixes the period-filter flicker in another inline comment.docs/02-interfaces.md"expense" section to drop theGetExpenseSummaryinteractor as a HomeScreen dependency, since the inline computation is fine for a reactive flow that already has the expenses.Don't just leave the deviation unacknowledged — the issue's literal signature has to match what the PR ships.
Suggestion:
getRecurringExpenses: GetRecurringExpenses = inject()is in the issue's example constructor but missing here. The "State:" bullets in the issue body never actually use it, so this is an internal inconsistency in the spec — but it should still be resolved one way or the other. Either add it (even unused, so the constructor matches) or update the issue to drop it. A short note in the PR body would suffice.@@ -0,0 +57,4 @@initialValue = emptyList(),)val summary: StateFlow<ExpenseSummary?> = combine(expenses, dateRange) { list, range ->Suggestion: When the user taps a different period chip,
summarybriefly flashes tonull(and the TotalCard to$0.00, plus the chart disappears) for the duration of the nextgetExpenses.subscribeByDateRangequery. The cause is theinitialValue = emptyList()onexpenses(line 57): whendateRangere-emits,flatMapLatestre-subscribes and emitsemptyList()before the new query returns, socombine(expenses, dateRange)emits(emptyList, newRange)and theif (list.isEmpty()) nullbranch on line 61 fires. Easy fix: drivesummarydirectly from adateRange.flatMapLatest { range -> getExpenses.subscribeByDateRange(range).map { ... compute summary ... } }flow so the empty initial value never reachescombine. (As a side benefit this also removes the need to re-deriveExpenseSummaryfrom the expenses list — see theGetExpenseSummarycomment.)@@ -0,0 +103,4 @@_recurringBanner.value = null}fun exportToCsv(uri: Uri, range: DateRange, onResult: (Result<Unit>) -> Unit) {Suggestion:
exportToCsvtaking(uri, range, onResult: (Result<Unit>) -> Unit)(line 106) forces the Screen to wrap the call incoroutineScope.launchfrom theExportActioncallback (HomeScreen.kt lines 116-122) and adds a callback hop for no benefit. Asuspend fun exportToCsv(uri: Uri, range: DateRange): Result<Unit>lets the Screen docoroutineScope.launch { val r = screenModel.exportToCsv(uri, range); if (r.isFailure) snackbarHostState.showSnackbar(...) }directly — thescreenModelScope.launchbecomes a one-liner and the callback plumbing goes away. The Screen already hascoroutineScopein scope.@@ -105,0 +110,4 @@<string name="home_period_this_month">this month</string><string name="home_manage_categories">Manage Categories</string><string name="home_see_all">See all</string><string name="home_recent">Recent</string>Nit:
home_fab_manual("Manual") andhome_fab_import("Import Bank Statement") are byte-identical toexpense_list_fab_manualandexpense_list_fab_import(strings.xml lines 75-76). A translator will translate the same English string twice. Either reuse the existing keys (rename to a sharedfab_manual/fab_import) or, if you want them independent, leave a one-line comment on the home entries explaining why HomeScreen needs its own copy. As written it's a translation-maintenance footgun.- 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)Verdict: clean
All three blocking concerns and the four suggestions from the previous round are resolved:
docs/04-implementation-plan.md,.opencode/agent/implementor.md(Charts + dependency table), and the issue body now describe a VicoColumnCartesianLayerwith a per-category legend. The deviation is explicitly justified in the PR's "Spec change" section.GetExpenseSummary— injected in the constructor and called from a new privatesummaryFlow(range), which feedssummaryviadateRange.flatMapLatest. The period-filter flicker falls out as a side benefit (the previouscombine(expenses, dateRange)+emptyList()initial value is gone;summarynow only emitsnullafter the real query returns with an emptybyCategory).ui/components/ExpandedFab.ktwith a cleanactions: @Composable ColumnScope.() -> Unitslot.ExpenseListScreenhad its privateTabAwareFab+MiniFabdeleted; theLaunchedEffect(selectedTab) { isFabExpanded = false }was hoisted to itsContent(). Both screens consume the shared component.isFabExpandedis also correctly hoisted out of the ScreenModel (it's UI state, not domain state) — nice cleanup.exportToCsv— now asuspend fun ...: Result<Unit>, called fromcoroutineScope.launchin the screen. Callback plumbing is gone.DateRangeOption.labelRes()+labelText()collapsed to a single source of truth used by bothTotalCardandPeriodFilter.expense_list_fab_manual/expense_list_fab_importwere deleted; both screens now use the sharedfab_manual/fab_import.getRecurringExpenses/appPreference— explicitly dropped from the issue body with a> Note:block explaining the rationale. Constructor matches the spec.I'm submitting this as
COMMENTper the same-account Gitea constraint — you decide when to merge.Closes #5.