ADR-0072: Activity empty state names its reason and recovery
- Status: Accepted
- Date: 2026-10-17
Context
Activity rendered one EmptyState whenever the filtered rows were empty, titled
“No transactions in this period”. When a search query or a category/direction
filter excluded every row of a non-empty month, the copy claimed the month
itself held no transactions, and the offered recovery (“clear your search
filters”) had no single action that cleared the query and the filters together.
Search text is owned by the screen, not echoed from the ViewModel, so a
ViewModel-only reset would leave the visible query stale.
Decision
Activity derives the empty reason in memory from the same month emission,
before and after the search/category/direction filters run — filtering stays out
of SQL (ADR-0006). A non-empty filtered result is NONE; an empty visible month
is EMPTY_MONTH; a month that holds rows whose rows were all excluded is
NO_MATCHES. The month and hidden scope are unchanged by this classification.
TransactionsUiState.emptyReason carries the reason to the screen:
EMPTY_MONTHkeeps “No transactions in this period” and the manual Add transaction recovery.NO_MATCHESrenders localized “No matching transactions” with a Material Reset filters action. That action clears the visible query and the category/direction filters together (updating the locally owned search field), while preserving the month and the show-hidden state.
The recovery is composed beside the existing shared EmptyState; the component
itself is unchanged, so no gallery fixture is added.
Consequences
A no-match search no longer misstates the month, and the one reset action restores the expected rows without touching month or hidden scope. The state shape is additive (a defaulted enum), so existing callers and previews compile unchanged.
Addendum (#247): the all-history scope
Activity gained an explicit scope: the selected month (default) or the whole ledger. The classification above is unchanged for the month scope; the all-history scope extends it with one more reason, so neither empty state can claim the wrong slice:
EMPTY_HISTORY— nothing is visible in the whole ledger; keeps Add transaction, and its copy is visibility-aware (No visible transactions in your history) because hidden rows may still exist while show-hidden is off.NO_MATCHESin the all-history scope renders No matches in your history (still Reset filters); there is no wider scope to offer.NO_MATCHESin the month scope keeps No matching transactions and Reset filters, and now also offers Search all history, which switches scope while preserving the query, category, direction and show-hidden state.
The all-history source reads every row unbounded, newest first
(TransactionRepository.observeAll() = observeVisible(), and
observeAllIncludingHidden() = a dedicated SELECT * FROM transactions ORDER BY
occurredAt DESC), so pre-1970 rows and a row at exactly Long.MAX_VALUE are
not dropped by a window; no SQL search is added (ADR-0006). The in-memory
filter, per-day fold and day grouping (ActivityDayGroup via buildDayGroups)
run in the ViewModel on the qualified CPU dispatcher
(DispatcherModule.filterDispatcher = Dispatchers.Default), so the screen
renders prebuilt day groups and does no whole-ledger groupBy/date work during
composition.
Verification
TransactionsViewModelTest covers truly empty, query-excluded,
category-excluded, direction-excluded and filters-on-an-empty-month cases, plus
the all-history scope: older merchant and note matches, the unbounded
pre-1970/epoch/positive/Long.MAX_VALUE source, independent
category/direction/query exclusions, filters set before switching scope,
switching back to the month, multi-year ordering and grouping, hidden-only
EMPTY_HISTORY with show-hidden off and on, all-history NO_MATCHES, a
50 000-row off-main filter+group test, and a non-gating performance harness that
prints timings/heap (see ui.md).
TransactionsContentTest asserts the filtered copy and that Reset filters
clears the visible query plus both filters, that a genuinely empty month
keeps the manual add, and the scope chips, the Search all history action,
the all-history empty states and the year-bearing all-history day header.
ActivityListMotionTest keeps the empty fallback.
ActivityEmptyRecoveryLayoutTest renders the filtered-empty recovery in a
requiredSize box at 320 dp / 640×320 dp with 200% text and asserts — with
performScrollTo() before assertIsDisplayed() — that the copy and both
recoveries stay reachable and never clip the narrow edge (review fix: the
recovery surface scrolls and its actions wrap).