Hoist the date-range filter chip group and divider out of the
LazyColumn and into a Column wrapper, so the chips sit directly
under the Expenses/Recurring tabs and the list fills the rest of
the page. Previously the chips were the first item of the
LazyColumn, which left a large empty area between the tabs and
the chips.
Also add Modifier.fillMaxSize() to the Recurring tab's LazyColumn
so it fills the pager page (no chips to hoist, but the layout
is now consistent with the Expenses tab).
#41: pipe amount input through sanitizeAmountInput() (digits + single
decimal) in both AddEditExpenseScreenModel and AddEditRecurringScreenModel
so KeyboardType.Decimal is no longer bypassable via paste or hardware keys.
#42: wrap DateField's OutlinedTextField in a Box.clickable so the whole
row (label, text, icon) opens the date picker, not just the trailing icon.
#43: replace CategoryDropdownField with a new CategoryPickerField that
opens a generic ListSearchDialog (search bar, scrollable list, swatch,
checkmark on selected row), decoupled from the Preference machinery.
ListSearchPreferenceWidget is unchanged.
Main moved ahead with the home-polish (#29/#32/#33), category HSV color
picker (#30), and category lock-icon fix (#31) PRs. The only conflicts
were in ExpenseListScreen.kt:
- Imports: main added AppBar + EmptyStateIllustration. The branch had
already removed AppBar (replaced by SearchToolbar) and replaced the
inline text empty state. Resolution: keep the branch's
AppBarTitle/SearchToolbar/TabText set, drop AppBar (no longer used),
add EmptyStateIllustration.
- ExpensesTabContent empty state: main replaced the Box+Text
'No expenses yet' with EmptyStateIllustration. Resolution: use
EmptyStateIllustration (consistent with main's new design) but keep
the branch's three-way message selection (no results vs no data).
For visual consistency between the two tabs, also swap
RecurringTabContent's empty state to EmptyStateIllustration. No
behavioral change for the recurring case — same per-tab vs no-results
message selection, same strings, just rendered through the shared
component.
All other files (build.gradle.kts, libs.versions.toml, HomeScreen,
SettingsScreen, CategoryScreen, EmptyStateIllustration.kt, strings.xml)
auto-merged cleanly.
Replace the inline OutlinedTextField inside ExpensesTabContent with
the shared SearchToolbar in the topBar. The query is shared across
both tabs; closing the toolbar (X / navigate-up) deactivates search
and clears the filter.
In ExpenseListScreenModel:
- searchQuery becomes StateFlow<String?> (null = inactive, "" = active
empty, "foo" = active query)
- setSearchQuery now accepts String? to match SearchToolbar's
onChangeSearchQuery signature
- expenses and recurring combine on
_searchQuery.debounce(SEARCH_DEBOUNCE_MILLIS).distinctUntilChanged()
so fast typing does not re-filter on every keystroke
- recurring is now filtered by query against category name and
recurring note (case-insensitive substring)
In ExpenseListScreen:
- Remove the inline search field, the date-range-filter search hint
label, and the now-unused OutlinedTextField / Icons.Outlined.Search
imports
- Both ExpensesTabContent and RecurringTabContent now take a nullable
searchQuery and show a generic 'No results' message when the active
filter empties the list, or the per-tab empty copy otherwise
- Add the expense_list_no_results string
Replace the plain Text inside PrimaryTabRow's Tab with the shared
TabText composable. Pass the current size of the expenses and
recurring state flows as badgeCount so each tab shows a small pill
with the (post-filter) list size. Counts update reactively as
items are added, deleted, or filtered by search/date.
Add ExpandedFabScrim composable that renders a Material 3 scrim
overlay fading in/out in sync with the mini-FABs. Tapping the scrim
dismisses the FAB.
Move the ExpandedFab out of Scaffold's floatingActionButton slot and
into the body inside a Box, so the scrim can match the body size via
matchParentSize() and stack above the list but below the FAB. Add a
BackHandler that dismisses the FAB on system back while it is open.
Wrap getExpenses.awaitOne, upsertExpense.await, getRecurringExpenses.awaitOne
and upsertRecurringExpense.await in withContext(Dispatchers.IO) { ... } so
the suspending Room calls run off the main thread. State-flow updates stay
inside screenModelScope.launch, which is Main-bound, and execute after the
withContext block returns.
- 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)
- Add HomeScreenModel with expenses/summary/recurring-banner/fab state flows and a getExpenses + processDueRecurring + exportExpensesToCsv + expensePreference constructor
- Replace the HomeScreen stub with a Material 3 dashboard: AppBar (Export + Settings), total card, period filter, Vico ColumnCartesianLayer chart with per-category legend, manage-categories/see-all actions, recent expenses, and an expanded FAB exposing Manual + Import sub-actions
- Add home strings and a home_recurring_banner plurals resource
- matchesQuery: use %.2f format to match the display (was Double.toString, which can use scientific notation and disagree with the row's %.2f)
- Remove unused Switch import in AddEditRecurringScreen (the active toggle uses ToggleItem)
- Extract CategoryDropdownField to ui/components/CategoryDropdownField.kt so both add/edit screens share one implementation; takes label as a parameter
- Remove unused recurring_list_active string
Drop the Context injection from the ScreenModel. Emit a sealed
ImportSnackbarMessage carrying either a @StringRes id or a dynamic
text (for the error.message case where the bank importer surfaces a
useful 'BRI import not yet implemented' string). The screen resolves
resource ids via stringResource() in Composable scope and shows the
snackbar; the original error.message info is preserved via the
Dynamic variant instead of being dropped.
The three snackbar strings in ImportBankStatementScreenModel were
hardcoded English while the rest of the app uses stringResource. Move
them to res/values/strings.xml. Also drop the
error::class.simpleName fallback in the failure branch — it surfaced
Kotlin class names like NotImplementedError to end users. Now falls
back to the localized 'Import failed' string.
Gitea blocks self-approval and self-requested-changes, so the reviewer
agent (which always reviews its own work on this same-account setup)
must always submit with state: COMMENT. Verdicts go in the summary body;
blocking concerns are marked inline with **Blocking:** / **Nit:** /
**Suggestion:** prefixes so the implementor can triage them.
DateField seeded the picker with ZoneId.systemDefault() but converted
the selectedDateMillis back with ZoneId.of("UTC"). For any non-UTC
user, the field and the picker displayed different days, and
confirming without re-picking silently shifted the export range by a
day. Use UTC on both sides (the DatePickerState contract is that
selectedDateMillis is UTC midnight of the picked day).
screenModelScope is backed by PlatformMainDispatcher (Main.immediate),
so direct interactor calls run DB queries and file I/O on the UI
thread. Switch reactive flows with .flowOn(Dispatchers.IO) and wrap
suspend calls in withContext(Dispatchers.IO).
Move architecture, package structure, dependencies, and implementation
rules into .opencode/agent/implementor.md (default primary agent). Add
.opencode/agent/reviewer.md (read-only primary agent) with PR review
workflow that leaves inline review comments only -- no issue creation,
no edits, no commits. AGENTS.md is now the shared context both agents
load: workflow, git/PR conventions, and docs index. Set
default_agent: implementor in opencode.json.