Skip to content

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_MONTH keeps “No transactions in this period” and the manual Add transaction recovery.
  • NO_MATCHES renders 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_MATCHES in the all-history scope renders No matches in your history (still Reset filters); there is no wider scope to offer.
  • NO_MATCHES in 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).