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, selected month| DRILL["transactions/drill"]
    TX[transactions]
    HOME -->|tap row| TD["transaction/{id}"]
    HOME -->|review strip| 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] -->|app-bar + New category / swipe right| CE["category/{id}?monthStart={monthStart}"]
    BUD -->|View transactions| DRILL
    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. An external link is dropped while the app is locked, so a crafted URI cannot stage a pre-filled editor for after unlock (issue #307).

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 (Home / Budgets / Reports drill-down; detail-style push) optional monthStart, categoryId (0 = uncategorised only)
budgets BudgetScreen —
reports ReportScreen —
settings SettingsIndex searchable task index; opens settings/task/{task}
settings/task/{task} SettingsScreen task id: banks, automation, currency, appearance, privacy, data, about
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}?monthStart={monthStart} CategoryEditScreen (id 0 = create) id, optional monthStart (plain routes default to current month)

The bottom-bar Activity destination's user-facing name is Activity: the tab label (nav_activity) and its top bar (tx_title) agree, while the route id, deep links, and the TransactionsScreen / TransactionsViewModel names keep transactions — an internal identifier is not user-facing copy (ADR-0067).

Settings tasks push settings/task/{task} and hide the tab bar. Currency holds base/enabled currencies and exchange rates (ADR-0039); Banks & capture holds tracked apps and notification/location toggles; Categories & automation holds parse rules and keyword editors. Most controls save immediately; rates and keywords keep explicit Save, and destination toolbars and system Back ask before discarding unsaved edits (ADR-0048). The Backup & data import reads the picked document off the main thread; a malformed, empty, unsupported, unreadable or oversized file shows a localized live-region error instead of terminating the app, and a failed ledger write rolls back — either way the ledger is unchanged (ADR-0055, ADR-0076, ADR-0077).

MANUAL_TRANSACTION and UNMATCHED_CREATE share TransactionDetailScreen — the ViewModel picks the mode from SavedStateHandle args (manual == "true", or unmatchedId != null).

Unsaved transaction changes (ADR-0046)

The editor compares editable content with a baseline published after loading manual prefill, an existing transaction (including items/files), or an unmatched notification. Toolbar and system Back share an exit guard: changed drafts offer Save, Discard, and Keep editing; pristine or reverted drafts leave directly. The keyboard and child overlays consume Back first. The confirmation survives configuration recreation; the navigation-scoped ViewModel retains the draft. Save is serialized and exits after the transaction, items, attachment metadata, category override, and unmatched dismissal commit together — the atomic write is owned by domain/TransactionSaveUseCase (#222), not the ViewModel. Validation or persistence failures keep the editor open. Discard deletes only newly staged files, including ones removed from the draft, preserving original rows/files. A bounded draft descriptor — editable scalars, item text, staged-file references, an immutable ownership manifest and accumulated loss flags, never attachment bytes — is mirrored into SavedStateHandle, so an editor recreated after process death replays the draft and clears the descriptor only on a successful Save, an explicit Discard or a Delete (#243, ui/transactions/TransactionDraftState.kt). The payload is capped at 32 KB (UTF-16 code units): an oversized draft degrades in stages — free text is trimmed, items/attachments are dropped — and the user is warned with copy that names the loss, which is retained across successive recreations; an over-long amount is restored empty for re-entry rather than truncated into a different valid value. A trimmed or omitted baseline can never read as pristine: the draft is then treated as dirty, and the ownership manifest (not the baseline) decides which bytes Discard may delete.

Every staged file is durably reserved under the draft's id in filesDir/staged-draft-reservations.json (data/repo/StagedDraftReservations.kt) the moment it is staged, and the ADR-0075 orphan sweep honours those reservations before unlinking, so a recoverable draft is protected even before its editor ViewModel exists and across a second process death. The store is written crash-safely (a unique temp file, fsync, then an atomic replace that never deletes the destination first) and read fail-closed: if it exists but cannot be read or validated, the sweep deletes nothing for that pass (FileCleanup.skipped). Ownership is released only after a save commits — never inside the save transaction, so a rollback keeps it — on Discard/Delete, or once the path has a persisted row. One in-process exclusion (AttachmentRepository; the app declares no second android:process) spans the sweep's whole decision — both reservation snapshots, the DB reference read and the unlink — and every reservation mutation and the attachment-row commit takes the same exclusion, so no reservation can be created and settled between the snapshots and no commit can land between the reference read and the unlink; the snapshot union stays as defence in depth. Terminal cleanup (Discard/Delete) reconciles against the attachments table before unlinking, so a crash between a save commit and the descriptor's retirement can never let Discard delete a committed row's bytes. Reservations expire after 30 days so an abandoned draft does not shield bytes forever; a staged file that already vanished is dropped — reported as a loss — never crashing and never losing the scalar edits. If the reservation file is lost entirely the sweep treats it as empty, and the 10-minute grace window is the only remaining protection.

Unsaved category changes (ADR-0057)

The category editor compares the editable draft — name, icon, and monthly-limit text — against a baseline published after the asynchronous load or a successful save. Toolbar and system Back share one guard: changed drafts offer Save, Discard, and Keep editing; pristine or reverted drafts leave directly. The IME and the icon-picker sheet consume Back first. Save validates and persists before exiting, so validation or persistence errors keep the draft and error visible; Discard writes nothing, leaving the selected month and any effective limit untouched. The navigation-scoped ViewModel retains the draft and the confirmation is saved Compose state, so configuration changes neither lose a draft nor raise a false prompt. No process-death recovery is provided.

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), and an external link is ignored entirely while locked (issue #307).

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 is internal-only: the builder still emits it, but the parser ignores it so an external caller cannot spoof bank provenance (issue #307). 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. The launch intent is consumed once: a recreation (rotation or process return) with saved instance state does not replay the already-open link, while onNewIntent still delivers a fresh warm link.

Adaptive navigation

AutoBudgetNavHost computes WindowWidthSizeClass:

  • Compact (phones) → a single bounded NavigationBar row (bottom, 72 dp at standard text, 80 dp at large text minimum before the system navigation inset, 48 dp per item). The five destinations are Home, Activity, Budgets, Reports, Settings; Home is the start destination and Settings is last. Selected destinations use the filled icon with the Material 3 tonal pill, unselected destinations use the outlined icon, so selection is legible without colour. Full localized labels are measured at the current font size; when the five equal-width items do not fit the available width the row scrolls horizontally behind an edge fade instead of wrapping to a second row (ADR-0044), preserving order, full labels, and direct access. The selected destination is always kept fully in view: an externally selected (restored or deep-linked) tab that would fall outside the row is brought back with a short, minimal scroll keyed to the route and measured item bounds, so its label and indicator are never offscreen. The scroll is animator-scale aware and never fights a manual scroll.
  • Medium / Expanded (tablets, desktop) → NavigationRail (left), NavHost in a weighted Row. The rail uses the same filled/outlined icon pair and selection pill as the compact bar. It composes only when showNavigation is true; detail routes (transaction/category editors, settings tasks, drill-downs) return before the container, so no empty 80 dp strip pushes their content (issue #297). A window shorter than the five items (for example 320 dp height, or 200 % text) scrolls the rail vertically and reveals the current/restored destination with the smallest move on selection or resize; labels may wrap to two lines. RTL places the rail on the right through the shell's layout direction rather than a separate branch (#385).
  • App bars → 64 dp minimum content height, 16 dp horizontal and 8 dp vertical inset, title in the 22/28 bold screen role (AppTextRole.screenTitle) with the status-bar inset consumed at the bar. Home's app bar shows the compact month control instead of the product name; the other destinations show their name. The shell owns the bar seam, so every top-level destination measures the same bar.
  • One add action → a 56 dp primary FAB sits 16 dp from the content edge and above the bottom bar on Home, Activity and the Activity drill-down. Budgets, Reports and Settings have no transaction FAB; Budgets instead carries a 48 dp app-bar New category action that opens the category editor for the displayed month, so it is that destination's one add affordance. Ledger content extends underneath the floating FAB; each FAB destination adds ShellMetrics.fabClearance at the end of its content so the last row can still scroll clear of the button.
  • Detail routes hide the tab bar exactly as before; a saved/restored tab, deep link, app-lock gate and predictive back keep their existing behavior.

Home adapts to its available content width (after the rail) through the shared isWideContentWidth breakpoint: below 600 dp it keeps the single-column flow; at 600 dp and above it places the month/summary and review status beside recent activity. Content is centered and bounded to 1200 dp overall (600 dp in the single-column layout). Both columns share one vertical scroll surface, with summary-before-activity accessibility traversal; month/data state stays in the existing ViewModel through resize (ADR-0053). Home statistics measure the widest label word or monetary token and use three, two or one equal columns at the active font scale (ADR-0043, ADR-0088); a statistic never shrinks or loses digits, and its recent rows put full amounts below merchant copy. A section action stacks under its title when both cannot hold their text, and Home's View all keeps the shell FAB's end column clear. On phones the compact vertical rhythm (8 dp section spacing and a denser hero card) keeps the summary and at least two complete recent rows above the fold at 320 dp / 100% text; the outer Scaffold reserves the measured bottom-bar height so the single navigation row never covers a row (#143). When a capture-status notice is shown (#242) it takes the top of that fold, so the notice, summary and the first complete recent row are guaranteed instead.

Budgets and Reports share Home's width contract through AdaptiveContentColumn (ADR-0066): the destination's single column is centred and capped at 1200 dp when the available content width is 600 dp or more (600 dp below), keyed off the width left after the navigation rail, not the device model. At that breakpoint Reports pairs the month/snapshot block with the donut and trend so the snapshot never pushes the charts out of the first viewport.

Tab switches crossfade in 180 ms. Pushed detail screens fade with a 32 dp slide toward the logical start over 240 ms; Back reverses the motion. Both screens move in the same direction, including in RTL layouts. Onboarding completion fades. Tapping the selected tab does nothing (ADR-0042). Onboarding steps move directionally: Next fades and slides the step content 24 dp toward the logical start over 200 ms with the progress bar animating to match, and Back reverses it toward the logical end (ADR-0042/ADR-0060). The transition is bounded, animator-scale aware (scale 0 settles immediately), and the outgoing step's controls are made inert until the motion settles, so a step change can never run a duplicate action. A month change crossfades the period label with its month-scoped result block over the same short, bounded duration (ADR-0071).

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.

Home View all pushes Routes.transactionsDrill(state.monthStart, null): the selected Home month, all directions and all categories. Back returns to the originating Home month. The plain Activity tab retains its independent month and filters; Reports keeps its optional category scope (ADR-0042).

Screens & view models

One subsection per destination: purpose, navigation entry/exit, state owner, notable shared components, and where its tests live. Component internals are in Shared components; each behavior decision links to its ADR, and end-user copy lives in the user guide.

Onboarding (onboarding)

  • Purpose — 4-step wizard: consent → notification access → location → bank selection. The access step leads with a decorative illustration, a Newsreader display heading and one tonal benefit card. While access is off, its primary action opens the special notification-access settings and Use manual tracking for now stays below it; once polling reports access, the primary becomes an enabled Continue with no auto-advance. The manual route finishes with capture off and no tracked packages (ADR-0058); the bank step lists installed apps only, preselects them, and offers Show all supported apps (ADR-0059); every step shows a localized Step N of 4 at the logical end opposite Back at the logical start, with an in-app Back after consent (ADR-0060). System/predictive Back follows the same step contract: after consent it steps the wizard back, while on consent it is left to the activity so the user can leave. Polls listener status every 2 s.
  • Navigation — the start destination until onboarding completes; finish pops the wizard and enters home. The graph's start destination is read once per launch and does not swap reactively, so completion cannot reset or duplicate the back stack.
  • State owner — OnboardingViewModel; persists tracked_packages, capture_location, onboarding_complete.
  • Components — shared step frame (StepFrame): header with Back and the step count on opposite logical edges (48 dp Back target) above a rounded progress track, one filled primary action pinned in the navigation-bar-aware bottom area, with optional/manual actions de-emphasised below it; selection and benefit surfaces use AppSurface.grouping (ADR-0069). The bank step scrolls its whole content with the primary action pinned, so a 320 dp height or 200 % text can never collapse the bank list to zero height (ADR-0087). Step 2 composes a decorative, TalkBack-skipped illustration from theme roles only (pale tonal field, dashed rings, tilted notification tile, two floating ledger tiles) that shrinks on short or large-text windows before the scrollable copy. Step content animates between steps (directional fade + small logical-axis offset, matching progress animation); the outgoing step's controls are inert while its content fades out.
  • Tests — instrumented ui/onboarding/; guide: getting started.

Home (home)

  • Purpose — monthly snapshot: capture-status line or notice (a quiet active line, neutral manual-only guidance, or a repair warning + Turn on when configured capture lost notification access), one compact review strip with the pending count and a single Review action that never expands (ADR-0049), the hero card (spent / income / biggest spend / merchant count, plus a complete budget pulse when every limit converts), recent 6 transactions, and a foreign-currency footnote. The app bar carries the compact month control instead of the product name; the shell FAB is the only add action, including in the empty state (ADR-0058). When a notice is shown it is Home's first content element — the shared title-to-content gap anchors on it. The hero is the app's single forest-green emphasis surface (AppSurface.hero / onHero / heroMuted / heroAccent, AppTextRole.heroAmount, ADR-0069, ADR-0079); the review strip is a restrained AppSurface.grouping card with the paired AppSurface.primaryText / supportingText roles and a tonal Review action, so it never competes with the total. Capture status, the single review strip and then the hero keep the same order at both breakpoints (ADR-0084). The budget pulse shows N% of <limit> budget, a clamped accent progress bar and the exact remaining/over amount; it is omitted when the summary is incomplete, so no partial percentage reaches the hero. The hero's budget-unavailable line is gated on a configured positive limit: no limit or a zero cleared sentinel shows neither the pulse nor a budget error, while an unrelated FX gap keeps its own footnote (ADR-0084). Hero statistics measure the widest word or monetary token and use three, two or one equal columns, so labels never break mid-word and amounts never split a digit at supported widths and text scales (a single amount wider than the whole card still wraps inside that one full cell, keeping every character) (ADR-0043, ADR-0088). The recent section's View all action stacks under its title when the row is too narrow and otherwise keeps the shell FAB's end column clear, so the floating add control cannot cover it. Foreign rows convert to the base currency, with a footnote when a rate is missing. A base-currency total that leaves Long range is rendered as an em dash (amount_unavailable, with localized TalkBack copy) rather than a partial number, and the same footnote carries the overflow copy.
  • Navigation — a bottom-bar tab; View all pushes the Activity drill route for the selected month; a row tap opens transaction/{id}; the review strip's Review action opens unmatched; the shell's add FAB opens transaction/manual/{manual} exactly like Activity and Budgets.
  • State owner — HomeViewModel (combine + flatMapLatest); its capture status combines the observed KEY_TRACKED_PACKAGES setting with the notification-access check, refreshed on lifecycle resume; the active line names up to two bank apps and appends the remainder as +N (#358). The month filter, FX folds and budget aggregation run on the injected @FilterDispatcher (flowOn), never on Main (#327).
  • Components — TransactionRow, MonthSelector, SectionHeader, EmptyState, ForeignCurrencyFootnote, AdaptiveContentColumn.
  • Tests — JVM and instrumented ui/home/; guide: Home.

Activity (transactions)

The bottom-bar Activity destination's user-facing name is Activity (tab label nav_activity and top bar tx_title agree); the route id, deep links, and TransactionsScreen/TransactionsViewModel keep transactions — an internal identifier is not user-facing copy (ADR-0067).

  • Purpose — month list with direction/category/search filters (ADR-0050, ADR-0079), swipe-right hide/unhide, swipe-left delete (confirm), and the shell add FAB. One compact period row sits at the top: the month selector and a single All history filter chip (unselected = the selected month, selected = the whole ledger). The chip carries a swap icon and its selected state (no dropdown chevron, because it does not open a menu), and a FlowRow wraps the pair instead of forcing one row at narrow widths or large text. The 48 dp filter icon lives in the app bar and opens the filter sheet, which also owns the show-hidden toggle. Typing in search filters the already-loaded list; the query is not part of the flatMapLatest key, so a keystroke never cancels and re-subscribes the DAO (issue #299). The query is also written to the ViewModel's SavedStateHandle, and the field is seeded from the restored ViewModel query, so process death cannot leave the field showing a query the list is not applying (#337). Status labels come from tx_status_* string resources (never the enum name) in both the row and the detail picker (#330). Each row also exposes Hide/Unhide and Delete as customActions on the same named, clickable row node TalkBack focuses for the transaction, so the actions are reachable without the swipe (issue #298, #386). The month scope is the default. A quiet summary line follows with the exact visible spend and row count (or the unavailable marker when a total cannot be represented). Search is collapsed by default: the app-bar search icon opens the shared field below the summary over 180 ms and Back closes it, so the ledger keeps the screen. While search is collapsed an active query is disclosed as a removable query chip beside the other filters, so the applied search is never invisible; opening the field hides that chip and shows the query in the field instead. Active direction/category filters render as removable chips. Clear filters appears only when a direction/category filter is active and deliberately keeps the search text; the query has its own removable chip. The filter sheet offers Uncategorised through the existing null-category sentinel. Rows lead with the merchant (AppTextRole.identity) over supporting category/source text and a 40 dp filled category avatar (secondaryContainer, ADR-0069). A hidden row keeps the normal onSurface / onSurfaceVariant / primary text roles on its surfaceContainerHighest background — no whole-row alpha — and adds a localized Hidden pill in the supporting line, so identity, metadata and money stay at normal-text contrast and the state is announced without relying on colour (#373). Each DayHeader is a quiet outlineVariant divider with a supporting label and an inline signed base-currency net that keeps its sign-currency-digits order in RTL (#405), plus a ForeignCurrencyFootnote when a day had unconvertible rows. An expense net is neutral onSurfaceVariant; income is the one positive accent; error red never marks ordinary spending. A day whose net leaves Long range — including an expense at the Long.MIN_VALUE sign-inversion boundary — shows an em dash (amount_unavailable) instead of a partial net, even when there is no numeric net to fall back on. Day headers and rows are keyed lazy items with appearance, disappearance and placement motion; a day header leaves with its last row, and a whole-month swap crossfades the label with the fresh ledger without replaying per-row motion (ADR-0071, #199). Day headers use the device locale's date order and month/weekday names (resolved through Android's best date-time pattern) in month scope, and add the year in all-history scope, so a cross-year result is never ambiguous and a US locale reads month-before-day while a UK locale reads day-before-month (#247, #386). An empty result names its reason (ADR-0072): a genuinely empty visible month keeps the private Add transaction recovery, while a month whose rows were all excluded by the query/category/direction filters renders localized No matching transactions with a Reset filters action that clears the visible query and both filters together, preserving the month and the show-hidden scope. The all-history scope extends the same classification (#247): an empty visible history reads No visible transactions in your history (manual-add recovery; the wording stays visibility-aware because hidden rows may still exist while show-hidden is off), and an all-history result whose rows were all excluded reads No matches in your history. A filtered-empty month also offers Search all history, which widens the same query/filters to the whole ledger — the query, category, direction and show-hidden state are preserved across the switch (and across opening a result and returning, since the ViewModel is retained and the search field is rememberSaveable). The recovery surface scrolls and its actions wrap (a FlowRow), so at 320 dp width, 200% text or a short landscape height the copy stays legible and Reset filters / Search all history both stay reachable instead of clipping off the narrow edge (review finding).
  • All-history source & performance — the all-history scope reads the whole ledger newest-first via TransactionRepository.observeVisible() / observeAllIncludingHidden(): one unbounded SELECT * FROM transactions ORDER BY occurredAt DESC (no occurredAt window, so pre-1970 rows and a row at exactly Long.MAX_VALUE are included). No SQL search is added (ADR-0006). The in-memory search/category/direction filter, the per-day fold and the day grouping (ActivityDayGroup via buildDayGroups) run in the ViewModel on the qualified CPU dispatcher (DispatcherModule.filterDispatcher = Dispatchers.Default, distinct from the IO pool) through flowOn; the screen renders prebuilt state.dayGroups only, so widening to the whole ledger adds no groupBy/date work to composition — LazyColumn bounds composed rows, not a preceding whole-list pass. The rows themselves are loaded by Room on its own query executor. The bound is the loaded ledger size, not a database text index.
  • All-history measurement — TransactionsViewModelTest .allHistoryScope_performanceHarnessMeasuresFilterAndGroupOffMain is a non-gating JUnit harness: it builds 50 000 rows at one-hour steps (≈6.8 years, multi-year), switches to all history, then prints the filter+fold+group wall-clock and heap delta for a common query, a blank query and a typing burst (c/co/coffee), asserting only a generous 10 s ceiling so shared CI cannot flake. Reference runs (this repo's CI container, JVM 25.0.4, Linux/amd64, 14 CPUs, 512 MB max heap) keep the CPU work in the tens of milliseconds, not seconds: common query merchant 48–108 ms / +38–39 MB; blank query 19–52 ms / +31 MB; typing burst (c/co/coffee) 41–93 ms for three passes / +42–47 MB — all on the injected dispatcher, never the main thread. The spread between runs is why the assertion is a generous ceiling and the printed numbers, not the assertion, are the signal.
  • Navigation — a bottom-bar tab; also the drill-down target for Home, Budgets and Reports (transactions/drill?monthStart=…&categoryId=…, detail-style push; categoryId=0 = uncategorised only). Rows open transaction/{id}.
  • State owner — TransactionsViewModel; the search field's editing text is owned by the screen, not echoed from the flow.
  • Components — TransactionRow, MonthSelector, SearchField, EmptyState, ForeignCurrencyFootnote.
  • Tests — JVM and instrumented ui/transactions/; guide: Activity.

Transaction detail (transaction/{id}, transaction/manual/{manual}, unmatched/{unmatchedId})

  • Purpose — add / edit / manual / unmatched-from-queue modes over one money-first editor (#263, ADR-0033): an Expense/Income segmented control leads, then the 40/48 amount field and its currency field, merchant, the always-visible category/status picker row, and the date row. Items, Note, Location and Attachments remain optional collapsible cards; the receipt thumbnail strip (AttachmentPreviewHeader) sits after them and reports the tapped attachment's index in the unfiltered attachment list, so a PDF before an image cannot open the wrong file (#328). Every saved attachment also has a named Open action: an image opens the existing viewer, while a supported non-image is handed to the system's read-only viewer through a one-file temporary provider grant (ADR-0086); missing files and absent viewers get localized feedback, and Delete stays a separate action. The full-screen viewer (ImageAttachmentViewerOverlay) is a dialog window with the receipt's file-name identity, a localized Close label, and bounded named zoom/pan/reset controls beside the gestures, keeping TalkBack focus out of the editor behind it (#334, #384). Amount and currency measure their text and reflow the currency field below amount when narrow, preserving draft and focus (ADR-0045). A stable seven-digit amount reserve keeps the currency trigger from jumping while a normal amount is typed; a longer exact value still measures and reflows rather than shrinking (#387). The currency trigger is a read-only outlined field in the amount field's family — same emphasized field shape (AppShape.amountField), hero text metrics, floating Currency label and trailing dropdown icon — and its menu is anchored to the trigger at the trigger's width on the themed M3 menu surface, with a 48 dp row per code and a leading check plus selected semantics on the current currency (#270); the dropdown offers the user-enabled subset plus the transaction's own currency (ADR-0039); field-scoped validation marks and clears the offending field (ADR-0056); the persistent full-width Save has a content-driven height with a 48 dp minimum, so its whole label stays inside the button at 320 dp/200 % while it sits above the keyboard/navigation insets and follows the scrolling content in TalkBack order (ADR-0051, #372). Item rows and attachment actions measure their available width and stack when 320 dp or 200 % text would collide (#387). The Location section shows a passive offline map preview for a saved point — LocationPreview reusing CameraPinMap, with honest full/overview/missing/corrupt/out-of-coverage/no-point states and an explicit Edit location action rather than a clickable coordinate row (ADR-0070). The picker also offers Enter coordinates for a non-gesture path: typed latitude/longitude values are validated against WGS-84 bounds, applied to the existing camera/canvas state, and saved through the same row fields without a permission, network or map-pack requirement; Save remains outside the dialog and reachable at short heights and 200 % text (#384, ADR-0086). Exit guard in Unsaved transaction changes.
  • Navigation — pushed from Activity, Home or the review queue; Back is guarded; Save pops back to the origin.
  • State owner — TransactionDetailViewModel; the mode is derived from SavedStateHandle args (manual == "true", or unmatchedId != null). The atomic save is delegated to TransactionSaveUseCase (domain/), so the ViewModel no longer opens a Room transaction (#222).
  • Date picker — pickedMillis is seeded from ui.occurredAt once the draft reports loaded, and the date row is not clickable before then; a picker opened on the zero-value first frame used to save 1970 (#295).
  • Missing target — a deep-linked id that resolves to no row (or a non-positive id, rejected by DeepLinks.parse) publishes a terminal notFound draft and the screen shows an error with Back instead of the non-dismissable loading dialog (#296).
  • Components — CollapsibleSectionCard, ItemizationSection, AttachmentSection, AttachmentPreviewHeader, ImageAttachmentViewerOverlay, QuadCropperOverlay, CameraPinMap, LocationPreview, ExportPreview, TransactionDeleteConfirmDialog.
  • Tests — instrumented ui/transactions/ and ui/components/; guide: Transaction detail.

Budgets (budgets)

  • Purpose — a month overview followed by per-category spending cards with over-limit states. The overview card (AppSurface.grouping, large shape) shows the total positive limit (compactAmount), spent, remaining and the uncapped percent, a single progress track; an incomplete summary shows — for every figure and no progress, never a partial total or a meaningless 0%. Groups read Over budget, On track and No limit through SectionHeader, each with its row count. The card uses the named roles (ADR-0069): the spent amount leads (compactAmount, primaryText), a supporting of X limit follows (supporting), then a 4 dp progress track inset in the card body and a status line with the uncapped % and, when over, Over by X. A category without a limit shows spent, No limit set and Set limit — no track. Each limit is the effective one — the most recent budgets row with monthStart ≤ the viewed month, carrying forward until changed or cleared (ADR-0036); sentinel 0 rows read as "no limit". Cards order by group, then uncapped utilization (spend ÷ limit) then spend, and the % label is uncapped (ADR-0040). The app bar carries the destination's one creation action — a 48 dp + (Icons.Default.Add, budget_new_category) whose New category content description opens category/{id}?monthStart=… for the displayed month — and no transaction FAB (issue #272). The card itself is one named Edit limit / Set limit action (role Button, ≥48 dp by geometry); its supporting limit line carries the visible pencil + label, so the action is reachable without discovering swipe or overflow. The trailing overflow keeps Edit limit / Set limit (a duplicate entry point), View transactions, Remove limit and Delete category separate (ADR-0047, ADR-0085). Swipe left only exists when the card has a destructive action — default categories without a limit clamp it and never paint the red reveal; a limit removed while the card is displaced settles it back. Swipe right still edits. spent is in base units with a footnote when a row was unconvertible. A category whose spend leaves Long range shows an em dash (amount_unavailable) and hides its derived percent/progress; the percent itself is exact BigDecimal math (clamped to Int) so an exact-maximal spend/limit pair reads 100. A saved limit that could not be converted to base reads budget_limit_unavailable (spoken as budget_limit_unavailable_description) with no derived progress, and still offers Edit/Remove/Clear — those actions are driven by whether a saved limit exists, not by the converted amount. Category cards are keyed lazy items with the same item motion, and each card owns a single rounded clip (the outer Card), so its swipe reveal can never trace the corners with a coloured seam in any theme (#199, #206). An empty month keeps the EmptyState explanation and uses the same app-bar action, so it is never a dead end and never shows a second New category button.
  • Navigation — a bottom-bar tab; View transactions pushes the Activity drill route with the displayed month/category; the app-bar New category action opens category/{id}?monthStart=… for the displayed month.
  • State owner — BudgetViewModel (combine + flatMapLatest).
  • Components — AdaptiveContentColumn, ForeignCurrencyFootnote, EmptyState; grouping surface AppSurface.grouping.
  • Tests — JVM and instrumented ui/budgets/; guide: Budgets.

Category editor (category/{id}?monthStart={monthStart})

  • Purpose — create/edit a category: name and icon lead, then an optional monthly limit — a restrained AppSurface.grouping card — for the selected month (ADR-0036); field-scoped validation marks only the offending field and clears it on correction (ADR-0056), and a failed Save focuses and scrolls to that field. Save is persistent above the IME (ADR-0051). A save commits atomically; while it is in flight the fields, icon picker, Clear limit and Save are disabled and Back is ignored, and a storage failure keeps the draft, shows retry copy and re-enables Save. Exit guard in Unsaved category changes.
  • Navigation — opened from Budgets (New category / Edit limit / Set limit) and Settings; id = 0 creates, and plain routes default to the current month; Back is guarded.
  • State owner — CategoryEditViewModel.
  • Components — IconPicker; the field column is bounded and centred by AdaptiveContentColumn on tablet widths (ADR-0066, ADR-0087).
  • Tests — JVM and instrumented ui/budgets/ (CategoryEdit*); guide: Budgets.

Reports (reports)

  • Purpose — a month insight card, a category donut with an app-side legend, and a 6-month trend whose header owns the window total; the month label opens MonthPickerDialog beside ‹/› and Today. The insight card is the screen's one forest-green hero surface: a month-over-month headline in AppTextRole.periodLabel, a neutral comparison line and View transactions (ADR-0069, ADR-0079). When the comparison cannot be made honestly the headline is omitted, never guessed. The donut card and trend card use AppSurface.grouping and are titled Spending by category and Monthly trend (last 6 months); the trend header shows 6-month total with the exact window sum. Totals/slices/trend are in base units (FX); sub-2% slices merge into one "Other" row (ADR-0029); a warning card replaces the insight block when the base currency has no rates. Availability is tracked per figure: an overflowing month total or category slice and a partial trend month render as amount_unavailable (legend, donut and trend), and the 6-month total is unavailable whenever any source month is partial. At 600 dp or more the insight block sits beside the donut and trend so the charts stay in the first viewport (ADR-0066). Selecting a slice keeps its category and View transactions drill-down after the four-second tooltip dismisses; a second tap toggles it off, and a month or slice-set change clears it. The drill-down keeps a measured ≥48 dp target (the Material text-button default is 40 dp) and resolves scope once (normal category, UNCATEGORISED_CATEGORY_ID for uncategorised, month-only for the merged Other bucket) and carries a unique accessible name including the slice label (ADR-0084). Chart internals live in Charts.
  • Navigation — a bottom-bar tab; View transactions pushes the Activity drill route scoped to the selected month and optional category.
  • State owner — ReportsViewModel (combine + flatMapLatest); the month and six-month filters, FX folds and slice build run on the injected @FilterDispatcher (flowOn), never on Main (#327).
  • Components — MonthSelector, MonthPickerDialog, AdaptiveContentColumn, ForeignCurrencyFootnote, EmptyState.
  • Tests — JVM and instrumented ui/reports/; guide: Reports.

Settings (settings, settings/task/{task})

  • Purpose — a searchable task index (ADR-0048) opening one screen per task: Currency (ADR-0039), Banks & capture, Categories & automation, Appearance, Privacy & lock, Backup & data, About. The index owns the app's one Settings search field; task screens have no duplicate search. It groups tasks under localized headings (issue #187) — Tracking, Preferences, Privacy & data and About — and a group with no search match is omitted. Task ids keep routing and the search index unchanged; a group heading is shown only when the group holds more than one row, so the lone About row never repeats its heading. Task screens use the shared 64 dp ShellTopAppBar with one Back affordance and a content column at screenEdge / titleToContent, bounded and centred by AdaptiveContentColumn so a tablet does not stretch one-column settings copy across the window (ADR-0066, ADR-0087). Each section's uppercase 10/16 eyebrow label sits outside and above its 20 dp SettingsSectionCard; the card has no inner header circle or divider, and its rows stay transparent (ADR-0063, ADR-0068, ADR-0079).
  • Navigation — a bottom-bar tab; the index opens settings/task/{task} (banks, automation, currency, appearance, privacy, data, about) and settings/rules/{package} for the rule editor; task screens hide the tab bar. An unknown task id resolves to a terminal screen with the shared app bar and Back, never an empty frame (#358).
  • State owner — four focused ViewModels (issue #323): SettingsRootViewModel (currency pickers, banks, rules, keywords, theme, notifications, location), FxSettingsViewModel (the exchange-rate editor and base switch), SecuritySettingsViewModel (lock, PIN, biometrics) and BackupViewModel (export, import, delete-all, map packs). Each has at most four dependencies, so its tests assemble only that concern; the screen composes them together.
  • Components — SearchField, SettingsRow/SettingsNavigationRow, SettingsSectionCard.
  • Security card — Privacy & lock hosts the SecuritySection card: App lock plus the opt-in screenshot/Recents protection toggle, applied as whole-window FLAG_SECURE by MainActivity before content composes (ADR-0078); off by default, so the docs capture tooling is unaffected.
  • Currency safety (issue #231) — picking a different base currency opens a confirmation (BaseCurrencyChangeDialog) explaining that the saved exchange rates are cleared and must be re-entered; confirming swaps base + rates in one transaction, cancelling leaves both untouched, and re-picking the current base is a no-op. The card (ExchangeRatesSection) renders one FxEditorState (base + rates + revision + the single in-flight operation + flags). Save, base switch and reload share one operation owner: a second request while one runs is refused, its control disabled and the refusal surfaced as feedback, and every completion is applied only to the operation/revision that started it. Currency/rates observations carry a write-path revision read in the same snapshot, so a read taken before a write is ignored however late it arrives — a completed save/switch is never rolled back by a pre-write snapshot — while a genuine external change still reconciles. A switch freezes the fields and publishes the cleared draft + new base as one update; a clean draft follows an external base change silently, while a dirty one is quarantined (stale) — its numbers stay visible under their own base's labels and cannot be saved until the user taps Reload exchange rates. A failed operation resets the operation state, keeps the draft and shows a retryable message; cancellation releases it without a message.
  • Export reliability (issue #238) — an export reports success only after the destination stream opened, the exact UTF-8 payload was written and the stream closed (ExportWriter); a provider that returns no stream, or an open/write/close failure, surfaces the failure message instead. Preparation is observable (ExportState.Preparing, export buttons disabled) and a preparation failure becomes a retryable error rather than an uncaught coroutine exception. The system destination picker is launched only from staged content (ExportState.AwaitingDestination), so a failed preparation never relaunches a stale picker and cancellation is never treated as success; a failed attempt is retried by tapping Export again.
  • Export coverage (issue #248) — the Backup & data export card carries a resource-backed scope summary (ExportCoverageNote): it states this is a transaction export rather than a complete app backup, lists what is included (visible transactions with items/notes, location snapshots, category/account identity) and excluded (hidden transactions, attachment bytes, budgets, settings/security, custom rules, correction mappings, the review queue), distinguishes the analysis-only CSV from the restorable JSON, and notes that no full app backup is provided so clearing app data is not recoverable. The same transaction scope is repeated in the success message. It claims no encryption, uses only wrapping Text (readable at 200% text) and adds no new query path — the exclusion facts are static copy, not a second snapshot.
  • Tests — JVM and instrumented ui/settings/; guide: Settings.

Rule editor (settings/rules/{package})

  • Purpose — per-package rule list with enable/disable, pattern edit, and a live test box; both test actions render the parsed amount through MoneyFormatter in the row's currency, matching the resulting transaction (ADR-0009).
  • Drafts — pattern edits stay in the ViewModel as drafts and are never persisted per keystroke, so incomplete typing cannot change the active capture rules. Each draft shows an inline diagnostic (invalid syntax or missing amount group) and explicit Save / Discard actions; Save is disabled while the pattern is invalid. An in-flight save is serialized per rule and clears only the revision it actually wrote, so an edit typed while the write is suspended stays dirty and keeps the Back guard honest.
  • Repair indication — a stored pattern that no longer compiles is skipped by capture and flagged in the card (even collapsed) until the user fixes and saves it.
  • Live test — Parse with all rules runs the enabled rules with drafts substituted in (the same subset capture uses); invalid drafts are skipped and listed with their label and specific reason, disabled rules are counted, and a disabled single-rule test is labelled as not applied during capture. Outcomes are typed (RuleTestOutcome) and rendered from string resources.
  • Back guard — toolbar and system Back share SettingsLeaveGuard: a dirty draft asks before discarding; a clean editor leaves directly.
  • Navigation — pushed from Settings → Categories & automation; Back returns to the task.
  • State owner — RuleEditorViewModel; renders the stateless RuleEditorContent.
  • Tests — JVM ui/settings/RuleEditorViewModelTest.kt, instrumented ui/settings/RuleEditorContentTest.kt; guide: Settings.

Review queue (unmatched)

  • Purpose — pending notifications grouped by source package (sticky headers) as per-item expand/collapse cards (2-line preview collapsed, friendly initials or ? for unknown apps, persisted-mask guidance or an honest unknown reason, Create/Dismiss — ADR-0052). A card shows money only for one unambiguous currency amount, under a Detected in notification eyebrow and never as a confirmed payment; several amount-like tokens show a "possible amounts" notice and a balance/limit/fee mention shows an ambiguity notice (ADR-0083). The cue check is global and conservative, so a valid amount can be omitted when the same text mentions a balance or fee. The metadata takes the full card width and carries zero-width break opportunities after thousands separators, so complete Long-formatted values wrap at 320 dp/200%. Long-press copies one JSON in debug builds; the header row has total count, destructive Clear all (confirm, soft-dismiss — ADR-0041) and a debug-only copy-all-for-developer export; the copy hint, copy action and export share showDiagnostics (ADR-0083). Cards use AppSurface.grouping; the source avatar is a filled secondaryContainer badge and sticky source headers stay on the plain screen surface for restrained grouping. Create transaction is the filled primary action and Dismiss a quieter text action (ADR-0069); both keep a measured ≥48 dp target at 320 dp / 200 % text instead of Material's compact 40 dp button default. Queue cards are keyed lazy items that animate on create and dismiss; the sticky group header leaves with its last card without duplicating itself (#199).
  • Navigation — opened from Home's single Review strip; Create transaction pushes unmatched/{unmatchedId}; Back returns to the queue.
  • State owner — UnmatchedViewModel; renders the stateless UnmatchedContent.
  • Components — EmptyState.
  • Tests — JVM and instrumented ui/unmatched/; guide: Review queue.

Lock (LockScreen)

  • Purpose — PIN dots, biometric prompt, a lockout panel, and a failed-attempt countdown; everything a deep link opens sits behind it (ADR-0012). The screen composes the shared roles: a primaryContainer brand badge, the 27/32 title role built from periodLabel, body copy from AppTextRole.status, ≥48 dp actions, and the error family only for a real failure or the lockout panel (ADR-0069).
  • PIN input — the custom dots remain the only visible indicator, backed by a single BasicTextField with PasswordVisualTransformation: it still accepts hardware-keyboard and assistive input, but exposes password semantics, masks the editable text, suppresses copy/cut actions and announces the entered digit count through its state description (#370). The keypad remains a 3×4 grid of ≥48 dp targets.
  • Insets — the opaque lock surface consumes WindowInsets.systemBars (status and navigation bars; a display cutout is a separate inset type and is not claimed here) and compacts its vertical rhythm below 640 dp. The IME is deliberately not consumed: padding it collapsed the 320×640 lock viewport and broke hardware PIN entry on the CI image. No element is positioned relative to the IME: an open keyboard may overlay any lower content, and no guarantee is made that Unlock, biometrics, the keypad or the PIN field remain visible while it is open (ADR-0087).
  • Retry feedback — after a wrong PIN the status line announces MAX_FAILED_ATTEMPTS - failedAttempts as a polite live region, so failures 1–4 read 4/3/2/1 remaining; the fifth failure enters the existing lockout panel and a successful PIN verification clears the counter (#371).
  • Navigation — an overlay, not a route; shown on launch and after the auto-lock idle timeout. A fail-closed gate (AppLockGate in MainActivity) renders no navigation or content until AppLockViewModel resolves the lock state, so a cold start cannot flash protected UI and a resume relocks on the first frame; everything a deep link opens still sits behind it (ADR-0012).
  • State owner — AppLockViewModel.
  • Components — none shared (screen-local).
  • Tests — JVM and instrumented ui/lock/; guide: Security & privacy.

Reactive state pattern

Screen state is exposed as StateFlows held with stateIn(WhileSubscribed(5_000)). The four month-scoped data screens (Home, Activity, 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. Only inputs that change the SQL source (month, scope, show-hidden) belong in the flatMapLatest key; search and the other in-memory filters combine with the loaded rows afterwards (issue #299). Every month-scoped aggregation pass (Home, Activity, Budgets, Reports, the review queue) runs through the injected @FilterDispatcher with flowOn, so filtering and FX folds never execute on Main (#327). Screens collect with collectAsStateWithLifecycle(), so a destination in the back stack stops its Room observers and WhileSubscribed aggregation; ui-guard rejects a plain collectAsState() (#338). Settings composes its sections from cached flows, including installed-bank detection scanned once off Main and refreshed by package broadcasts — never PackageManager IPC inside the combine (issue #294). Aggregation (spent/income/per-category) is computed per screen in the ViewModels — there are no SQL aggregations (ADR-0006: filtering is in-memory).

Design language (ADR-0062–ADR-0069, ADR-0079)

One token set, one Warm language and one implementation per reusable control — screens do not re-decide any of them.

  • Spacing lives in ui/theme/Spacing.kt: xs 4 dp, sm 8 dp, compact 12 dp, md 16 dp, hero 20 dp, lg 24 dp, xl 32 dp. Screen-level tokens: titleToContent 8 dp — the gap from a destination's app bar to its first element, which every top-level destination applies as its outer top inset — and screenEdge 16 dp for the horizontal inset. The screen rhythm is one step: sm owns the first-control→next-block and in-section block gaps on every destination (ADR-0062). sectionGap 24 dp is the only separation before a SectionHeader; it may not be replaced by a raw 24.dp or Spacing.lg, and a misplaced sectionGap fails ui-guard rule 7. Card interiors keep their own deliberate steps and are not flattened by the shared screen step.
  • Typography lives in ui/theme/Type.kt. DM Sans supplies interface weights 400/500/600/700; Newsreader 400 supplies the hero amount and period label. Newsreader replaces the mockup's DM Serif Display because it has real tabular figures; every monetary style requests "lnum", "tnum" (MONEY_FONT_FEATURES, ADR-0079). Named roles (AppType / AppTextRole) are the public API; Material's type slots alias them, so no ad-hoc TextStyle belongs on a themed surface.
  • Shape lives in ui/theme/Shape.kt: extraSmall 4, small 8, medium 16, large 20, extraLarge 28 dp. Named meanings in AppShape map repeated controls to those steps: field (medium) for merchant/date/note and item fields, amountField (extraLarge) for the money-first Amount and Currency pair, grouping (large) for section/category cards and chip (extraSmall) for compact status labels; pills use CircleShape at the call site. Screens compose AppShape instead of re-picking a corner (#387).
  • Controls live once in ui/components/: the search field (SearchField) is shared by Activity, the Settings index and the icon picker; every settings row (SettingsRow / SettingsNavigationRow) shares one vertically centred layout with a 36 dp leading tile; and every settings section (SettingsSectionCard) owns one rounded card surface, so its rows stay transparent and its label sits outside. A screen may not re-implement a control that already lives in ui/components/. scripts/ui-guard.sh enforces the reuse rule, the screen rhythm (rule 6), the section gap (rule 7), palette ownership (rule 8) and gallery coverage (rule 9); it runs in the docs-guard CI workflow (see tooling.md).
  • Content motion follows the scope of the change (ADR-0071). A single item's appearance, disappearance and placement animate with the keyed lazy item (Modifier.animateItem): Activity day groups/rows, Budgets category cards and Review-queue cards; a group header leaves with its last row. A change of visible period crossfades the period label with its month-scoped result block (Activity's ledger, Budgets' list, Home's hero, Reports' snapshot) over the one shared duration; each layer renders its exact formatted snapshot, so money is never interpolated and a new label is never paired with the previous month's totals while data loads. A changed slice set clears any chart selection. The shared transition lives once in ui/components/MonthSelector.kt (MonthScopedCrossfade); a whole-month swap composes a fresh list, so it never replays per-row appearance. Compose animations honour the system animator scale, so scale 0 settles immediately. Navigation reveal uses Motion.NAV_REVEAL_MILLIS; the lock rejection shake uses Motion.shakeAmplitude (8 dp) through the real density, so it is the same physical distance on every screen (ADR-0087). The lock's 27/32 title is a deliberate lock-specific size built from the shared display role; Home's 12/16 stat override and the trend bar's 10 sp value are similarly explicit local size choices, not duplicate type owners. A swipe card owns exactly one rounded clip (the outer Card); the reveal underlayer is painted only while the card is displaced, so no second anti-aliased edge can leak a reveal colour at the corners (#199, #206).

Emphasis, surface and type roles (ADR-0069, ADR-0079)

Named in ui/theme/Roles.kt; compose these instead of re-picking a tier or a raw colour. With dynamic colour off, the static Warm palette supplies the hero, nav and accent literals; with dynamic colour on every role is a step of the active ColorScheme, so no brand hex mixes into the wallpaper scheme. The type roles come from AppType, so light/dark and dynamic colour inherit automatically.

Role Value Use
AppSurface.grouping surfaceContainerLow one group / section card; repeated ledger rows stay plain (no per-row surface, no nested cards)
AppSurface.hero / onHero Warm forest #1D5136/#224B33, else primary / onPrimary the one Home hero; Reports' month insight reuses the same strong surface
AppSurface.heroMuted Warm #C9DDCE/#C3D8C7, else full onPrimary supporting copy on the hero; the full paired role stays contrast-safe for every generated hue (#388)
AppSurface.heroAccent Warm #F3C978, else tertiaryContainer budget-pulse progress and the illustration dot on the hero; the container tone keeps ≥3:1 from the dynamic hero and its track. The onboarding illustration sits on a pale grouping tile and uses tertiary for its decorative dot (ADR-0087)
AppSurface.nav Warm #F1F4EA/#141A12, else surfaceContainer bottom bar and rail
AppSurface.income / expense primary / onSurface positive amount accent / neutral expense amount
AppSurface.primaryText onSurface primary copy and every financial amount
AppSurface.supportingText onSurfaceVariant supporting copy, limits, descriptions
AppTextRole.heroAmount 40/48 Newsreader + tabular, left-to-right Home monthly total
AppTextRole.periodLabel 24/32 Newsreader month label and the Reports insight headline
AppTextRole.compactAmount 24/32 DM Sans semibold + tabular, left-to-right category spending, overview total, Reports donut and 6-month totals
AppTextRole.identity 14/20 DM Sans semibold merchant / category name
AppTextRole.amount 14/20 DM Sans semibold + tabular, left-to-right transaction row amount, day-header net, Home statistics, itemization total, detected review metadata
AppTextRole.supporting 12/16 DM Sans limits and descriptions
AppTextRole.status 14/20 DM Sans medium status in the ledger row and editor picker; utilisation labels
AppTextRole.eyebrow 10/16 DM Sans bold, 1 tracking settings section label; detected-amount metadata label

The error family signals an actual failure or an over-limit state only: the amount stays primaryText/neutral, while an over-limit message and its progress use the contrast-corrected error role. Direction and state never rely on colour alone — they also carry text or an icon. A financial amount is never truncated, size-reduced, k/M-abbreviated or horizontally scrolled to fit. ui-guard rule 5 fails when a destination hardcodes a heavy container instead of AppSurface.grouping, and rule 8 fails a raw colour literal outside ui/theme.

A monetary value is one left-to-right run whatever the ambient paragraph direction is (#405, ADR-0079): the display string authors the order (the surface's sign, MoneyFormatter's currency symbol and grouped digits) and the money type roles pin TextDirection.Ltr, so an RTL paragraph cannot move a leading +/- to the visual end (RM 45.00-). Only the paragraph direction is pinned — the string, Long minor units, grouping and the locale's currency symbol/scale are unchanged, and the surrounding layout keeps its own RTL reading, mirroring and focus order. It covers the value surfaces (ledger row, day-header net, Home hero, the itemization total line and the other money-role consumers); an amount substituted into a prose sentence rendered with a prose role stays part of that sentence's direction.

Shared components (ui/components)

Component Used by
TransactionRow Home, Activity; the one 72 dp ledger row with a 12 dp vertical inset, leading with the merchant identity over supporting category/source text and a 40 dp circular secondaryContainer avatar that holds the category icon or merchant initial (decorative, with no extra accessibility node) — ADR-0069; measures the merchant and full signed amount before choosing the single-line or stacked layout, so a large amount moves below the merchant with a Spacing.sm gap instead of colliding and money is never clipped, ellipsised or abbreviated (#198); expense amounts are neutral and income is the one accent; the layout is measured, never caller-flagged (no forced-stack or trailing-slot API, #358); always formats the amount in the transaction's own currency (tx.currency), never the settings display currency; every child is placed direction-relative, so in RTL the avatar and identity mirror to the logical start and the inline amount pins to the logical end, with the stacked amount starting at the identity's logical start (#275); the signed amount itself keeps its authored sign-currency-digits order in an RTL paragraph through the money role's left-to-right bidi policy (#405); a hidden row keeps its full text roles and adds a localized Hidden pill instead of a whole-row fade (#373)
ForeignCurrencyFootnote Home, Budgets, Reports; explains that some foreign rows were excluded when no FX rate is set
MonthSelector Home, Activity, Budgets, Reports; compact inline period label in the 24/32 Newsreader role between two 48 dp arrows, with no pill and no background of its own; 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; the label crossfades to the committed period over MONTH_TRANSITION_MILLIS (ADR-0071, ADR-0079)
MonthScopedCrossfade Home, Activity, Budgets, Reports; crossfades a month-scoped result block to its new-period snapshot when the visible month changes, leaving in-month updates (and their item motion) untouched (ADR-0071)
MonthPickerDialog Reports; year stepper + full localized month grid, returns the picked month-start via TimeRange.monthStartEpochMillis; ReportsViewModel.selectMonth guards non-positive values back to the current month. The grid measures the widest full name and uses 3/2/1 columns (3 only when the name fits unwrapped), wraps rather than clips at 320 dp / 200 % text, and scrolls when the available height cannot show every row. Each month cell is ≥48 dp and is a selectable node, so selection is announced by colour and semantics (#331, ADR-0087)
AdaptiveContentColumn Home, Budgets, Reports, Settings index/tasks and the category editor; centres the destination column and bounds its width to 1200 dp wide / 600 dp narrow, keyed off the available content width after the navigation rail (ADR-0066, ADR-0087)
EmptyState Home, Activity, Budgets, Reports, Unmatched
SectionHeader Home, Budgets; the one section heading with an optional trailing action/count that measures and stacks when needed
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. Columns are adaptive with a 48 dp minimum (never below the target at 320 dp), each tile is a selectable node, and the search index precomputes labels once instead of recompiling regexes per keystroke (#331)
ItemizationSection Transaction detail; stateless itemization editor inside its section card (single header row + placeholder-only name/amount rows, add/remove, item total with its currency in the shared tabular money role, mismatch warning); a narrow card or 200 % text stacks the amount/delete row below the name — ADR-0024, #380/#387
AttachmentSection Transaction detail; stateless attachment list inside its section card (type icon + file name + size, Take photo / Add file, and named Open/Delete per row); the two add actions flow onto a second line when 200 % text or a narrow card would collide. Open routes images to the in-app viewer and supported non-images to a one-file read-only provider grant; Delete stays separate — ADR-0027, ADR-0086, #383/#387
CollapsibleSectionCard Transaction detail sections; independent expand/collapse card on the restrained AppSurface.grouping surface with role-based title/summary (ADR-0069), icon, wrapped collapsed-value summary and rotating chevron (ADR-0033, ADR-0061); the header announces a localized Expanded/Collapsed state and an Expand/Collapse click label (#386); seeded expanded when content exists, auto-expands Attachments on growth and Items on item-related save errors
AttachmentPreviewHeader Transaction detail, below the core fields and above the optional sections; horizontal thumbnail strip of IMAGE attachments (placeholder uses AppSurface.grouping), tap opens the viewer (ADR-0033). Each tap reports the attachment's index in the unfiltered list, so a PDF before an image opens the tapped image (#328)
ImageAttachmentViewerOverlay Full-screen receipt viewer in its own dialog window; shows the file-name identity, announces the image and current zoom, keeps pinch/pan plus named 48 dp zoom in/out, reset and four pan controls bounded to 1–6×, and dismisses through tap-out at 100 % or the localized Close button; opaque control surfaces keep contrast off the photograph, unreadable images get explicit feedback, and TalkBack focus stays inside the viewer (ADR-0033, ADR-0086, #334/#384)
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). The four corners are also named, focusable 48 dp targets with a position state description and four bounded move actions (5% step) that route through the same clamp as drag; pointer drag keeps priority and the controls disappear while busy (#273). Its control strip — Cancel, rotate, Done, invalid-geometry help and busy indicators — composes under a local matched dark ColorScheme because it sits on the opaque black preview, with resolved roles floored to >= 4.5:1 and explicit disabled content instead of Material's 0.38 alpha (#274)
TransactionDeleteConfirmDialog Transaction detail; shared delete confirmation (title/body/confirm/cancel). The Activity list currently builds an equivalent inline dialog instead of reusing it (known duplication).
CameraPinMap Location picker and editor preview; 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; the MapState camera is hoisted so the caller reads the pinned cameraPosition.center, and interactions/uiOptions parameters let the passive preview pass MapInteractions.None and MapUiOptions.None. The style asset is read on the IO dispatcher through OfflineMapStyle.loadAsync and cached per tile URL/theme, so opening a located transaction never reads it on the first frame (#335). Falls back to the ADR-0026 grid canvas without map data; the picker offers Locate current, Enter coordinates (typed WGS-84 latitude/longitude applied to the same camera/canvas state), Clear location, and a one-shot auto-locate on open when the transaction has no coordinates (ADR-0030, ADR-0070, ADR-0086)
LocationPreview Transaction detail Location section; passive offline map preview for a saved point (CameraPinMap with no interaction bindings) with honest full/overview/missing/corrupt/out-of-coverage/loading states, a supported coordinate line, and explicit Edit location (or Choose location when none) — ADR-0070
SearchField Activity, Settings index, icon picker; the app's one search pill (leading Search icon, inline placeholder, clear action, 48 dp minimum) — ADR-0064
SettingsRow / SettingsNavigationRow Every Settings section; one 48 dp row layout with a 12 dp vertical inset and the trailing control on the row's vertical centre, transparent (the hosting section card owns the surface); a leading slot uses the shared 36 dp primaryContainer tile, and SettingsNavigationRow adds that tile with a chevron — ADR-0063
SettingsSwitchRow Every immediate on/off setting (Notify on capture, Record transaction locations, Dynamic color, App lock, screenshot protection, Biometric unlock, Device credential fallback); the row is the single named Role.Switch toggle and the child switch is decorative, with a shared checked/enabled state — ADR-0063, ADR-0082, #377
SettingsSectionCard Every Settings section — the task screens and the Settings index task list; one Material card (MaterialTheme.shapes.large 20 dp on surfaceContainerLow) with a 16 dp inset hosting only the section's rows; the uppercase eyebrow label sits outside and above the card, and there is no inner header circle or divider — ADR-0068, ADR-0079

The maintained gallery is debug-only: app/src/debug/java/com/zharif/autobudget/ComponentGallery.kt renders the real shared controls in ComponentGallery — light and dark in one scroll — with light/dark/narrow-large-text @Previews. Its inventory is ui/components/gallery/GalleryInventory.kt, guarded by GalleryInventoryTest (JVM), so a component or a meaningful state cannot silently disappear. It adds no navigation route and never ships. Debug-only labels live in src/debug/res/values/strings.xml.

Update rule: a new or changed shared control must add/update its GalleryFixture entries in the same change; use the real control, deterministic fixture data and only the states the control actually exposes. There is no Loading fixture because no covered control has a loading state. Account screens additionally rely on AppSurface/AppTextRole (ADR-0069).

Reuse checklist: compose the control from ui/components/; add its states; check light/dark and narrow/large text; keep fixture data out of production flows. The full workflow (owners, evidence rules, per-feature string XML) is design-contribution.md; the committed verification evidence is warm-ledger-verification.md.

Theming

ui/theme:

  • AutoBudgetTheme(darkTheme, dynamicColor) → Material 3 scheme from the Warm ledger light/dark palettes (Color.kt); dynamic colour on Android 12+ when enabled replaces every role with a scheme step (ADR-0079).
  • ThemeMode (SYSTEM / LIGHT / DARK) + dynamic colour persisted in settings (KEY_THEME_MODE, KEY_DYNAMIC_COLOR); MainActivity applies edge-to-edge system-bar contrast to match.
  • Type.kt owns the bundled DM Sans and Newsreader families plus every named type role and the tabular money features; Shape.kt owns the 4/8/16/20/28 dp steps; Spacing.kt owns the rhythm; Motion.kt owns every duration, offset, amplitude and the one easing curve, and a raw tween(<number>) outside the theme fails scripts/ui-guard.sh (#358); Roles.kt owns the Warm surface/type roles and the dynamic-colour fallback.
  • LocalDarkTheme exposes the resolved dark flag (map basemap, ADR-0032) and LocalDynamicColor exposes whether the static Warm palette is in use.

The full token table, the Newsreader substitution rationale and the money-figure policy are in ADR-0079; the verification evidence is in warm-ledger-verification.md.

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). The total uses the selected font scale and moves above the ring when its measured label/amount cannot fit the 104 dp center area; outside the hole it can wrap without truncation (ADR-0028). Legend and tooltip share one percent formatter (formatPercent, rounding half-up, <1% below one percent) so the two never disagree. Availability is honoured everywhere, and never as a partial number: an unavailable total renders the centre/above-ring amount as amount_unavailable (spoken as amount_unavailable_description) and replaces the wedges with one neutral, non-quantitative ring (slicesToPieData with an unavailableColor) so no partial amount defines a wedge angle; a partial slice's legend row and tooltip show the same marker (spoken as the description) while unaffected slices keep exact amounts, and while the total is unavailable no percent is shown (a share of an unknown total is not a number). Ring taps map through selectDonutSlice only when every amount is available, so an unavailable ring never mis-selects. Legend rows are the donut's full-width tap targets: heightIn(min = 48.dp) is applied before the internal 8/6 dp padding, and Modifier.selectable wraps that padding and background, so a tap anywhere in the row toggles the same single selection as the ring (Role.Checkbox, selection state keyed to slices plus the viewed month's selectionKey). Selection is separate from tooltip visibility: the bubble dismisses after four seconds, but the selected row and drill-down persist until toggle-off, a new month or a changed slice set (ADR-0084). Each one-line row therefore owns at least 48 dp of non-overlapping layout height; wrapped amounts grow the row instead of clipping, while labels keep their existing one-line/two-line ellipsis caps (issue #262, #276).
  • 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. A month whose amount is partial draws no library bar but a distinct full-height unavailable marker (never a zero-height bar, so it cannot be read as a genuine zero) and is excluded from the axis scale; its tooltip reads amount_unavailable and its contentDescription reads the localized amount_unavailable_description. The chart enters its no-data state only when no month is unavailable and the exact values establish no spending, so a real expense is never hidden. The axis ceiling/ticks are built with checked/arbitrary-precision arithmetic (niceTrendAxis), so an exact-maximum month cannot wrap the ceiling negative, and indicator labels format a Long tick directly (no Int saturation). Selection state is keyed to the data and the period (remember(trend) for the trend; remember(slices, selectionKey) with monthStart for the donut, including its pointer handler), so switching months never shows a stale tooltip or legend selection.

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 AppRoles.grouping (surfaceContainerLow), the actual card surface (#339). 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; the merge is skipped when any slice is unavailable or the total is unrepresentable, so a partial amount is never ranked against a ghost total. 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 (the toolchain is Kotlin 2.4.21); 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.

Toggle accessibility

Each immediate on/off row (App lock, screenshot protection, biometric, credential fallback, Notify on capture, Record transaction locations, Dynamic color and every tracked bank app in Settings) exposes exactly one switch-role node. SettingsSwitchRow owns the single toggleable(value = …, role = Role.Switch) node while its nested Switch is decorative and mirrors the same checked/enabled state (ADR-0082). Assistive tech therefore announces the row's localized name and on/off state once, and touch/keyboard activation reach the same action. The switch is bound to confirmed persisted state; cancelling the PIN-setup, disable-confirmation, notification-permission or location-permission flow leaves it unchanged, and the row keeps a minimum 48 dp target.

Choices that are not immediate settings keep checkbox semantics: onboarding consent, onboarding location capture and the Show all supported apps filter. Onboarding's per-bank rows use the same trailing switch and Role.Switch as Settings, including the "Not installed on this device" subtitle.