Skip to content

ADR-0022: Custom category CRUD with category-aware delete

  • Status: Accepted
  • Date: 2026-08-15
  • Deciders: maintainers
  • Related: ADR-0007, ADR-0004

Context

Categories ship as a fixed seeded set (defaults). Users want to create their own spending categories, edit them (name, icon, budget), and delete them. Because categories is 1:1 to budgets per month, category management lives on the Budgets screen. The existing UI edits budgets via a popup dialog and only lets you swipe rows that already have a limit, and swipe-delete always resets the budget — it can never remove a category.

Keyword classification is also name-keyed (KEY_CATEGORY_KEYWORDS stores {"<category name>": [keywords]}), so a custom category must surface in the keyword editor and in the classifier, or it can never auto-classify and its keywords are lost on rename.

Decision

Add full category CRUD from the Budgets screen; no schema change (the categories table already has name, icon, kind, isDefault, sortOrder, and the cascade FKs already clean up budgets/corrections and null transactions.categoryId).

  • Create/edit via a dedicated screen (category/{id}, id == 0 creates), replacing the budget popup dialog. The screen edits name, icon (fixed list of standard Material icons) and the current-month budget (empty = no limit). Default categories keep their name locked (they back the seeded keyword lists); custom categories may rename.
  • Swipe is enabled on every budget row. Swipe-right opens the editor; swipe-left shows a confirm dialog whose action is category-aware:
  • default category → delete only the budget limit (previous behavior);
  • custom category → delete the category entirely; budgets/corrections cascade, transactions become uncategorised (SET NULL).
  • Custom categories flow into keyword classification. The keyword editor (Settings → Automation) shows one field per expense category, including custom ones. KeywordClassifier.effectiveKeywords() iterates all expense categories from the DB instead of only DEFAULT_KEYWORDS. Custom categories only use stored keywords (no seeded defaults).
  • Keyword storage is centralized in SettingsRepository (loadCategoryKeywords / saveCategoryKeywords / renameCategoryKeywordKey), replacing the two duplicated decoders in SettingsViewModel and KeywordClassifier. Renaming a custom category re-keys its stored keyword entry so matching survives the rename.
  • Duplicate category names are rejected case-insensitively via a new getByNameIgnoreCase DAO lookup; create assigns sortOrder = max + 1 and isDefault = false.

Consequences

  • Budgets, Reports, Home, Transactions filters and detail category chips all pick up custom categories automatically (they consume CategoryRepository.observeAll()).
  • Deleting a custom category is destructive but visible: confirm dialog states that transactions become uncategorised. Restoring requires re-creating the category.
  • Keywords remain name-keyed (v1). Renaming is restricted to custom categories; id-keyed keyword storage is a clean follow-up if rename/dedupe constraints tighten.
  • Export/import round-trips custom categories by name (import never creates categories — a category must exist before importing transactions that reference it).

Alternatives considered

  • Key keyword storage by category id — cleaner long-term but needs a settings-key migration, id-keyed default seeding and classifier rework; deferred (see #27 research).
  • Unique (kind, name) index — prevents duplicates at the DB level but adds a v7 migration that collides with #28/#29; deferred.
  • Keep editing in the popup dialog — rejected: the issue requires a page (name + icon + budget) that a dialog cannot fit comfortably.