UI Layer
Scope: owns navigation, screens, ViewModel pattern, shared components, charts, adaptive layout.
Jetpack Compose (Material 3), single-activity, navigation-compose, Hilt
view models. All screens live in ui/; shared components in ui/components.
Navigation graph
flowchart LR
START([startDestination: onboarding or home]) --> ONB[onboarding]
ONB -->|finish| HOME
HOME -->|View all| TX[transactions]
HOME -->|tap row| TD["transaction/{id}"]
HOME -->|badge| UN[unmatched]
TX -->|row tap| TD
TX -->|FAB +| MT["transaction/manual/true"]
UN -->|Create transaction| UC["unmatched/{unmatchedId}"]
TD -->|pop| HOME
TD -->|pop| TX
UC -->|pop| UN
MT -->|pop| TX
SETTINGS[settings] -->|rules| RULES["settings/rules/{package}"]
BUD[budgets] -->|FAB + / swipe right| CE["category/{id}"]
REP[reports]
REP -->|View transactions| DRILL["transactions/drill?monthStart={monthStart}&categoryId={categoryId}"]
Deep links (autobudget://transaction/{id}) arrive as a pendingRoute on
MainActivity and are navigated once the nav graph is attached — the
AutoBudgetNavHost pending-route effect is keyed on the first back-stack
entry so a cold-start deep link never races the graph setup.
Routes are defined in object Routes in AutoBudgetNavHost.kt:
| Route | Screen | Args |
|---|---|---|
onboarding |
OnboardingScreen |
— |
home |
HomeScreen |
— |
transactions |
TransactionsScreen |
— |
transactions/drill?monthStart={monthStart}&categoryId={categoryId} |
TransactionsScreen (Reports drill-down; detail-style push) |
optional monthStart, categoryId (0 = uncategorised only) |
budgets |
BudgetScreen |
— |
reports |
ReportScreen |
— |
settings |
SettingsScreen |
— |
settings/rules/{package} |
RuleEditorScreen |
package |
transaction/{id} |
TransactionDetailScreen (edit) |
id |
transaction/manual/{manual} |
TransactionDetailScreen (manual) |
manual=true |
unmatched/{unmatchedId} |
TransactionDetailScreen (from queue) |
unmatchedId |
unmatched |
UnmatchedScreen |
— |
category/{id} |
CategoryEditScreen (id 0 = create) |
id |
MANUAL_TRANSACTION and UNMATCHED_CREATE share TransactionDetailScreen —
the ViewModel picks the mode from SavedStateHandle args
(manual == "true", or unmatchedId != null).
Deep links (ADR-0031)
ui/navigation/DeepLinks.kt is the single owner of the autobudget://
contract: producers build links only through its builders, consumption goes
only through DeepLinks.parse(uriString); Routes.fromDeepLink is the thin
Uri → route adapter on top. Works cold start and warm start (onNewIntent);
everything a link opens sits behind the app lock (ADR-0012).
Grammar: autobudget://{target}[/{segment}][?key=value&…]
| Link | Opens |
|---|---|
autobudget://{host} where host is a tab route (home, transactions, budgets, reports, settings) or unmatched |
that screen (docs screenshot tooling) — note unmatched is a detail-style route, not a bottom-bar tab |
autobudget://transaction/{id} |
transaction detail |
autobudget://transaction/manual[?params] |
new manual transaction, optional prefill |
Manual prefill params (all optional, validated by DeepLinks, dropped when
invalid): amount = positive integer minor units (ADR-0009), currency
= supported ISO code, merchant ≤ 120 chars, note ≤ 500, source =
triggering package ≤ 120. Unknown hosts/segments/malformed required args ⇒
the link is ignored; unknown params are dropped — forward-compatible in both
directions.
Producers today: the captured-notification tap (ADR-0016), the Quick Settings
"Add transaction" tile (qs/AddTransactionTileService), and tooling tab
launches. There are deliberately no intent-filters and no exported components
beyond the launcher activity — see ADR-0031 for the security posture and how
to add a new entry point.
Adaptive navigation
AutoBudgetNavHost computes WindowWidthSizeClass:
- Compact (phones) →
NavigationBar(bottom). The Home tab sits in the center and uses the default Material 3 selected indicator like every other tab (no custom circle overlay). - Medium / Expanded (tablets, desktop) →
NavigationRail(left), NavHost in a weightedRow.
Tab switching uses direction-aware slide transitions (320 ms): sliding toward the selected tab right when moving down the bar, left when moving up; pushed detail screens always slide in from the end.
Tab navigation never restoreState for the start destination
(restoreStateForTab in ui/navigation/TabNavigation.kt): popUpTo(start) {
saveState = true } stores the popped stack under the start destination's id,
so restoring state on a navigation back to it would immediately re-open the
screen we just left. The Home tab therefore always lands on Home, while other
tab switches keep their scroll/filter state.
Screens & view models
| Screen | ViewModel | State/behavior |
|---|---|---|
OnboardingScreen |
OnboardingViewModel |
4-step wizard: consent → notification access → location (opt-in toggle + permission) → bank selection. Polls listener status every 2 s. Persists tracked_packages + capture_location + onboarding_complete. Finish also requests the POST_NOTIFICATIONS runtime permission (best-effort). |
HomeScreen |
HomeViewModel |
Listener-revoked banner, review-queue card, month selector, hero card (spent / income / biggest spend / merchant count), recent 6 transactions, unmatched badge. Foreign-currency rows convert to the settings base currency; a footnote appears when some rows had no conversion rate. |
TransactionsScreen |
TransactionsViewModel |
Month + direction/category/search filters (search matches merchant or note, case-insensitive; the field's editing text is owned locally by the screen, not echoed from the ViewModel flow), show-hidden toggle, swipe right to hide/unhide, swipe left to delete (delete confirm), FAB manual add. Rows have no alternating accent; each DayHeader shows an inline signed base-currency net total (green positive via colorScheme.primary, red negative via colorScheme.error), with a ForeignCurrencyFootnote when a day had unconvertible rows. |
TransactionDetailScreen |
TransactionDetailViewModel |
Add/edit/manual/unmatched modes with a sectioned layout (ADR-0033): always-visible core block (receipt preview header, amount+currency row, date & time pickers, merchant, direction chips) above collapsible CollapsibleSectionCards — Category & status (FlowRow chips), Items, Note, Location, Attachments — that toggle independently, seed expanded when content exists on load, auto-expand Attachments on growth and Items on item-related save errors, and show value summaries while collapsed; receipt photos via AttachmentSection's Take photo action open QuadCropperOverlay (system camera capture → edge-detected quad pre-position → perspective warp → staged IMAGE attachment per ADR-0033); image attachments render in AttachmentPreviewHeader at the top and open the zoomable ImageAttachmentViewerOverlay; location card opens the full-screen location picker overlay — an offline MapLibre basemap following the app theme, light or dark (ADR-0032), with a fixed center pin when map data is available (bundled z≤9 overview, street detail once a .pmtiles pack is imported in Settings; falls back to the ADR-0026 grid canvas), an on-map Locate current control top-end of the map, a Clear location action in the button row when coordinates are set, and a one-shot auto-locate on open when the transaction has no coordinates yet, per ADR-0030; manual itemization editor (ItemizationSection) with per-line name/amount, add/remove, live item total and a non-blocking mismatch warning; attachments section (AttachmentSection) with take-photo/add-file/delete and validation Snackbars; export-preview icon (top bar) that shows the transaction as exported JSON; save/delete (delete now confirms via TransactionDeleteConfirmDialog). |
BudgetScreen |
BudgetViewModel |
Per-category progress bars, over-limit states. Cards are ordered by budget utilization (spent ÷ limit, highest first, uncapped so over-budget ranks above at-limit), tie-broken by actual spend; categories without a positive limit trail, ordered by spend. FAB creates a category; swipe right opens the category editor (name/icon/budget); swipe left deletes — the confirm dialog removes a user-created category entirely (transactions become uncategorised) but only resets the limit for seeded defaults. The fill bar is clipped to the card's rounded shape so its right edge never shows a straight cut against the rounded border. spent is in base units (FX), with a footnote when some rows were unconvertible. |
CategoryEditScreen |
CategoryEditViewModel |
Create/edit a category: name (locked for defaults), icon, optional current-month budget (empty = no limit). The icon row opens a modal bottom sheet with a searchable, lazily-rendered grid over all Material "filled" icons (IconPicker). Save creates or updates the category (+ re-keys stored keywords on custom rename) and upserts/clears the budget. |
ReportScreen |
ReportsViewModel |
Snapshot card (with "View transactions" → pre-filtered Activity), category donut (ComposeCharts PieChart donut style; app-side angle/radius hit-testing over the widened ring band so thin slices select on the first tap, selected slice gets an emphasized fill, clickable legend rows stay in sync both ways), 6-month trend columns (ComposeCharts ColumnChart on a nice 1-2-5 axis with compact k/M labels, tap a bar for a value tooltip), month label opens a year/month picker (MonthPickerDialog) in addition to ‹/› and "Today". Tooltips auto-dismiss after 4 s and are clamped inside the chart area every layout pass (no detaching while scrolling). Sub-2% slices merge into one "Other" row (ADR-0029); future months show "No data for \<Month> yet" instead of capture copy. A one-time "tap for exact amounts" hint above the donut persists dismissal in encrypted settings. A warning card replaces the snapshot when the base currency has no exchange rates (all spending would be hidden); otherwise a footnote explains partial conversion. Totals/slices/trend in base units (FX). |
SettingsScreen |
SettingsViewModel |
Horizontally-scrollable section chips with edge fades: General (currency chips show ISO codes only, exchange-rate editor — one field per foreign currency, Save button when dirty; appearance, tracked apps), Automation (parse rules, notify-on-capture toggle, location-capture toggle — permission-gated, per-category keyword fields — one per expense category, custom categories included), Data (export/import/delete card; offline map pack import/remove in its own card beneath it, showing installed-pack name), Security, About (app-name explainer, version, build date, git SHA from BuildConfig, mailto: support link). |
RuleEditorScreen |
RuleEditorViewModel |
Per-package rule list, enable/disable, pattern edit, live test box (test this / test all). |
UnmatchedScreen |
UnmatchedViewModel |
Pending queue grouped by source package (sticky headers), per-item expand/collapse cards (2-line preview collapsed; tap expands full text + Create/Dismiss), long-press copies single JSON, header row with total count + copy-all-for-developer export (compressed base64). Renders stateless UnmatchedContent; export wiring lives in the screen. |
LockScreen |
AppLockViewModel |
PIN dots, biometric prompt, lockout panel, failed-attempt countdown. |
Reactive state pattern
Screen state is exposed as StateFlows held with
stateIn(WhileSubscribed(5_000)). The four month-scoped data screens (Home,
Transactions, Budgets, Reports) build it with the shared pattern
combine(...) + flatMapLatest over a _monthStart MutableStateFlow;
simpler screens (onboarding, lock, rule editor, category editor) use plain
MutableStateFlows without flatMapLatest. Aggregation
(spent/income/per-category) is computed per screen in the ViewModels — there
are no SQL aggregations (ADR-0006: filtering is in-memory).
Shared components (ui/components)
| Component | Used by |
|---|---|
TransactionRow |
Home, Transactions; always formats the amount in the transaction's own currency (tx.currency), never the settings display currency |
ForeignCurrencyFootnote |
Home, Budgets, Reports; explains that some foreign rows were excluded when no FX rate is set |
MonthSelector |
Home, Transactions, Budgets, Reports; month pill with ‹/› arrows; canGoNext/canGoPrevious disable an arrow at a navigation boundary and onJumpToday shows a "Today" button when viewing a past month; onLabelClick (Reports only) makes the label tappable and opens MonthPickerDialog |
MonthPickerDialog |
Reports; year stepper + 12-month grid, returns the picked month-start via TimeRange.monthStartEpochMillis; ReportsViewModel.selectMonth guards non-positive values back to the current month |
EmptyState |
Home, Transactions, Budgets, Reports, Unmatched |
SectionHeader |
Home |
CategoryIcon / categoryIconVector |
everywhere; resolves icon id (or legacy emoji) → Material icon |
IconPicker |
Category editor; searchable grid over the generated Material "filled" catalog (MaterialIconCatalog), lazy vector rendering |
ItemizationSection |
Transaction detail; stateless itemization editor inside its section card (single header row + placeholder-only name/amount rows, add/remove, item total, mismatch warning) |
AttachmentSection |
Transaction detail; stateless attachment list inside its section card (type icon + file name + size, Take photo / Add file / delete) |
CollapsibleSectionCard |
Transaction detail sections; independent expand/collapse card with icon, title, collapsed-value summary and rotating chevron (ADR-0033) |
AttachmentPreviewHeader |
Transaction detail top; horizontal thumbnail strip of IMAGE attachments, tap opens the viewer (ADR-0033) |
ImageAttachmentViewerOverlay |
Full-screen zoomable receipt viewer over a scrim; pinch/pan, tap-out or close button dismisses (ADR-0033) |
QuadCropperOverlay |
Receipt capture; full-screen perspective cropper with detection-pre-positioned draggable corner handles, 90° rotate, confirm hands geometry to the warp/stage pipeline (ADR-0033) |
TransactionDeleteConfirmDialog |
Transaction detail; shared delete confirmation (title/body/confirm/cancel). The Transactions list currently builds an equivalent inline dialog instead of reusing it (known duplication). |
CameraPinMap |
Location picker; offline MapLibre vector basemap (pmtiles://file:// PMTiles source, tokenized style asset resolved to light/dark per ADR-0032 + bundled glyphs) with a fixed center pin; camera state is hoisted so the caller reads the pinned coordinate. Fully offline per ADR-0030 |
Theming
ui/theme:
AutoBudgetTheme(darkTheme, dynamicColor)→ Material 3 scheme; custom green/teal light + dark palettes (Color.kt); dynamic color on Android 12+ when enabled.ThemeMode(SYSTEM / LIGHT / DARK) + dynamic color persisted in settings (KEY_THEME_MODE,KEY_DYNAMIC_COLOR);MainActivityapplies edge-to-edge system-bar contrast to match.Type.kt/Shape.ktdefine the design tokens.
Charts
ReportScreen renders its charts with
io.github.ehsannarmani:compose-charts
1.0.0 (ADR-0028, superseding ADR-0013):
- Donut (
ui/reports/charts/DonutChart.kt): libraryPieChartinPie.Style.Stroke; the library's label helper is disabled (LabelHelperProperties(enabled = false), on by default upstream) because legend rows stay app-side; center total and selection bubble stay app-side too (no pie popup/hole text upstream). Legend and tooltip share one percent formatter (formatPercent, rounding half-up,<1%below one percent) so the two never disagree. - Trend (
ui/reports/charts/TrendChart.kt): libraryColumnChartwith gridlines and a base-currency value scale; month labels are ComposeTexts and taps land on an overlay row of per-bar cells that also carry thecontentDescriptionsummaries (library canvas labels are invisible to TalkBack). The axis is a nice 1-2-5 scale (niceTrendAxisinChartAdapters.kt) with compactk/Mindicator labels; explicitindicator/gridlinelists keep ticks and lines aligned. Non-zero bars shorter than 3% of the axis height are floored to it so small months stay visible. Selection state is keyed to the data (remember(trend)/remember(slices)), so switching months never shows a stale tooltip.
All colors derive from the theme's full-tone accents via
buildReportChartPalette (ADR-0029): slots 0–2 are primary/tertiary/
secondary; slots 3–7 are hue-rotated, saturation-stepped derivatives of
primary; every slot is contrast-corrected to ≥3:1 against the card's
surfaceContainer. Semantic colors (error, containers, outline) are never
used. Slices under 2% of the month total merge into one "Other" row
(mergeSmallSlices) so hairline wedges stay legible. Mapping to library
models is pure Kotlin in
ChartAdapters.kt (JVM-unit-tested), and money strings are always formatted
from Long minor units via MoneyFormatter. The version is pinned because
newer releases require Kotlin 2.3.10; re-check metadata compatibility before
bumping. Both charts disable the library's label helper — for PieChart to
keep the legend app-side, and for ColumnChart because its default-on legend
renders an unlabeled color dot near the card title.