Skip to content

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.

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

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 weighted Row.

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); MainActivity applies edge-to-edge system-bar contrast to match.
  • Type.kt / Shape.kt define 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): library PieChart in Pie.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): library ColumnChart with gridlines and a base-currency value scale; month labels are Compose Texts and taps land on an overlay row of per-bar cells that also carry the contentDescription summaries (library canvas labels are invisible to TalkBack). The axis is a nice 1-2-5 scale (niceTrendAxis in ChartAdapters.kt) with compact k/M indicator labels; explicit indicator/gridline lists 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.