UI Layer
Scope: owns navigation, screens, ViewModel pattern, shared components, charts, adaptive layout.
Jetpack Compose (Material 3), single-activity, navigation-compose, Hilt
view models. All screens live in ui/; shared components in ui/components.
Navigation graph
flowchart LR
START([startDestination: onboarding or home]) --> ONB[onboarding]
ONB -->|finish| HOME
HOME -->|View all, 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.
Deep links (ADR-0031)
ui/navigation/DeepLinks.kt is the single owner of the autobudget://
contract: producers build links only through its builders, consumption goes
only through DeepLinks.parse(uriString); Routes.fromDeepLink is the thin
Uri → route adapter on top. Works cold start and warm start (onNewIntent);
everything a link opens sits behind the app lock (ADR-0012), 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
NavigationBarrow (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 weightedRow. The rail uses the same filled/outlined icon pair and selection pill as the compact bar. It composes only whenshowNavigationis 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.fabClearanceat 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;
finishpops the wizard and entershome. 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; persiststracked_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 useAppSurface.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 restrainedAppSurface.groupingcard with the pairedAppSurface.primaryText/supportingTextroles 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 showsN% 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 leavesLongrange 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 opensunmatched; the shell's add FAB openstransaction/manual/{manual}exactly like Activity and Budgets. - State owner —
HomeViewModel(combine+flatMapLatest); its capture status combines the observedKEY_TRACKED_PACKAGESsetting 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
FlowRowwraps 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 theflatMapLatestkey, so a keystroke never cancels and re-subscribes the DAO (issue #299). The query is also written to the ViewModel'sSavedStateHandle, 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 fromtx_status_*string resources (never the enum name) in both the row and the detail picker (#330). Each row also exposes Hide/Unhide and Delete ascustomActionson 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 normalonSurface/onSurfaceVariant/primarytext roles on itssurfaceContainerHighestbackground — 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). EachDayHeaderis a quietoutlineVariantdivider with a supporting label and an inline signed base-currency net that keeps its sign-currency-digits order in RTL (#405), plus aForeignCurrencyFootnotewhen a day had unconvertible rows. An expense net is neutralonSurfaceVariant; income is the one positive accent; error red never marks ordinary spending. A day whose net leavesLongrange — including an expense at theLong.MIN_VALUEsign-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 isrememberSaveable). The recovery surface scrolls and its actions wrap (aFlowRow), 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 unboundedSELECT * FROM transactions ORDER BY occurredAt DESC(nooccurredAtwindow, so pre-1970 rows and a row at exactlyLong.MAX_VALUEare included). No SQL search is added (ADR-0006). The in-memory search/category/direction filter, the per-day fold and the day grouping (ActivityDayGroupviabuildDayGroups) run in the ViewModel on the qualified CPU dispatcher (DispatcherModule.filterDispatcher=Dispatchers.Default, distinct from the IO pool) throughflowOn; the screen renders prebuiltstate.dayGroupsonly, so widening to the whole ledger adds nogroupBy/date work to composition —LazyColumnbounds 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_performanceHarnessMeasuresFilterAndGroupOffMainis 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 querymerchant48–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 opentransaction/{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 —LocationPreviewreusingCameraPinMap, 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 fromSavedStateHandleargs (manual == "true", orunmatchedId != null). The atomic save is delegated toTransactionSaveUseCase(domain/), so the ViewModel no longer opens a Room transaction (#222). - Date picker —
pickedMillisis seeded fromui.occurredAtonce the draft reportsloaded, 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 terminalnotFounddraft 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/andui/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,largeshape) 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 meaningless0%. Groups read Over budget, On track and No limit throughSectionHeader, 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 astatusline 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 recentbudgetsrow withmonthStart ≤the viewed month, carrying forward until changed or cleared (ADR-0036); sentinel0rows 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 openscategory/{id}?monthStart=…for the displayed month — and no transaction FAB (issue #272). The card itself is one named Edit limit / Set limit action (roleButton, ≥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.spentis in base units with a footnote when a row was unconvertible. A category whose spend leavesLongrange shows an em dash (amount_unavailable) and hides its derived percent/progress; the percent itself is exactBigDecimalmath (clamped toInt) so an exact-maximal spend/limit pair reads100. A saved limit that could not be converted to base readsbudget_limit_unavailable(spoken asbudget_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 outerCard), so its swipe reveal can never trace the corners with a coloured seam in any theme (#199, #206). An empty month keeps theEmptyStateexplanation 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 surfaceAppSurface.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.groupingcard — 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 = 0creates, and plain routes default to the current month; Back is guarded. - State owner —
CategoryEditViewModel. - Components —
IconPicker; the field column is bounded and centred byAdaptiveContentColumnon 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
MonthPickerDialogbeside ‹/› and Today. The insight card is the screen's one forest-green hero surface: a month-over-month headline inAppTextRole.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 useAppSurface.groupingand 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 asamount_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_IDfor 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
ShellTopAppBarwith one Back affordance and a content column atscreenEdge/titleToContent, bounded and centred byAdaptiveContentColumnso 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 dpSettingsSectionCard; 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) andsettings/rules/{package}for the rule editor; task screens hide the tab bar. An unknowntaskid 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) andBackupViewModel(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
SecuritySectioncard: App lock plus the opt-in screenshot/Recents protection toggle, applied as whole-windowFLAG_SECUREbyMainActivitybefore 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 oneFxEditorState(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 wrappingText(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
MoneyFormatterin 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 statelessRuleEditorContent. - Tests — JVM
ui/settings/RuleEditorViewModelTest.kt, instrumentedui/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 shareshowDiagnostics(ADR-0083). Cards useAppSurface.grouping; the source avatar is a filledsecondaryContainerbadge 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 statelessUnmatchedContent. - 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
primaryContainerbrand badge, the 27/32 title role built fromperiodLabel, body copy fromAppTextRole.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
BasicTextFieldwithPasswordVisualTransformation: 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 - failedAttemptsas 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 (
AppLockGateinMainActivity) renders no navigation or content untilAppLockViewModelresolves 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:xs4 dp,sm8 dp,compact12 dp,md16 dp,hero20 dp,lg24 dp,xl32 dp. Screen-level tokens:titleToContent8 dp — the gap from a destination's app bar to its first element, which every top-level destination applies as its outer top inset — andscreenEdge16 dp for the horizontal inset. The screen rhythm is one step:smowns the first-control→next-block and in-section block gaps on every destination (ADR-0062).sectionGap24 dp is the only separation before aSectionHeader; it may not be replaced by a raw24.dporSpacing.lg, and a misplacedsectionGapfailsui-guardrule 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-hocTextStylebelongs on a themed surface. - Shape lives in
ui/theme/Shape.kt:extraSmall4,small8,medium16,large20,extraLarge28 dp. Named meanings inAppShapemap 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 andchip(extraSmall) for compact status labels; pills useCircleShapeat the call site. Screens composeAppShapeinstead 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 inui/components/.scripts/ui-guard.shenforces 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 inui/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 scale0settles immediately. Navigation reveal usesMotion.NAV_REVEAL_MILLIS; the lock rejection shake usesMotion.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 outerCard); 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 |
Component gallery and reuse checklist
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);MainActivityapplies edge-to-edge system-bar contrast to match.Type.ktowns the bundled DM Sans and Newsreader families plus every named type role and the tabular money features;Shape.ktowns the 4/8/16/20/28 dp steps;Spacing.ktowns the rhythm;Motion.ktowns every duration, offset, amplitude and the one easing curve, and a rawtween(<number>)outside the theme failsscripts/ui-guard.sh(#358);Roles.ktowns the Warm surface/type roles and the dynamic-colour fallback.LocalDarkThemeexposes the resolved dark flag (map basemap, ADR-0032) andLocalDynamicColorexposes 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): libraryPieChartinPie.Style.Stroke; the library's label helper is disabled (LabelHelperProperties(enabled = false), on by default upstream) because legend rows stay app-side; center total and selection bubble stay app-side too (no pie popup/hole text upstream). 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 asamount_unavailable(spoken asamount_unavailable_description) and replaces the wedges with one neutral, non-quantitative ring (slicesToPieDatawith anunavailableColor) 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 throughselectDonutSliceonly 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, andModifier.selectablewraps that padding and background, so a tap anywhere in the row toggles the same single selection as the ring (Role.Checkbox, selection state keyed toslicesplus the viewed month'sselectionKey). 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): libraryColumnChartwith gridlines and a base-currency value scale; month labels are ComposeTexts and taps land on an overlay row of per-bar cells that also carry thecontentDescriptionsummaries (library canvas labels are invisible to TalkBack). The axis is a nice 1-2-5 scale (niceTrendAxisinChartAdapters.kt) with compactk/Mindicator labels; explicitindicator/gridlinelists keep ticks and lines aligned. Non-zero bars shorter than 3% of the axis height are floored to it so small months stay visible. 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 readsamount_unavailableand itscontentDescriptionreads the localizedamount_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 aLongtick directly (noIntsaturation). Selection state is keyed to the data and the period (remember(trend)for the trend;remember(slices, selectionKey)withmonthStartfor 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.