ADR-0022: Custom category CRUD with category-aware delete
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 == 0creates), 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/correctionscascade, 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 onlyDEFAULT_KEYWORDS. Custom categories only use stored keywords (no seeded defaults). - Keyword storage is centralized in
SettingsRepository(loadCategoryKeywords/saveCategoryKeywords/renameCategoryKeywordKey), replacing the two duplicated decoders inSettingsViewModelandKeywordClassifier. 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
getByNameIgnoreCaseDAO lookup;createassignssortOrder = max + 1andisDefault = 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.