Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/build-apk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@ jobs:
distribution: temurin
java-version: '17'

# Pinned: phosphor_flutter 2.1.0 (unmaintained) extends IconData, which
# newer stable Flutter marks final. Bump deliberately when replacing that dep.
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.41.9
cache: true

- run: flutter pub get
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/deploy-web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ jobs:
steps:
- uses: actions/checkout@v4

# Pinned: phosphor_flutter 2.1.0 (unmaintained) extends IconData, which
# newer stable Flutter marks final. Bump deliberately when replacing that dep.
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.41.9
cache: true

- run: flutter pub get
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@ jobs:
steps:
- uses: actions/checkout@v4

# Pinned: phosphor_flutter 2.1.0 (unmaintained) extends IconData, which
# newer stable Flutter marks final. Bump deliberately when replacing that dep.
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.41.9
cache: true

- run: flutter pub get
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,8 @@ lib/
router.dart go_router config and the auth redirect guard
supabase_client.dart Client init and the top-level `supabase` getter
theme.dart Material 3, seeded from a teal-green
icons.dart The app's icon vocabulary -- Phosphor icons,
category icon slugs, deterministic category tints
widgets/
page_body.dart Desktop max-width wrapper
features/
Expand All @@ -234,13 +236,15 @@ lib/
insights/ Monthly category breakdown
recurring/ Recurring templates
import/ Bank statement import: logic / data / providers / ui
labels/ Category + tag management (the group's 4th tab)
realtime/ Postgres change subscription per group
models/ Plain Dart classes mirroring DB rows
supabase/
migrations/ Numbered SQL, the schema's source of truth
test/ Unit tests for logic, widget tests for screens
docs/
statement-import.md Import pipeline design and fingerprint threat model
categories-and-tags.md Category/tag data model, icon vocabulary
push-notifications.md FCM setup handoff, see "Not built yet"
.github/workflows/ Test, web deploy, Android APK build
```
Expand Down
95 changes: 95 additions & 0 deletions docs/categories-and-tags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Categories & tags

Every expense can carry one category (an icon + a name — Food & Drink,
Rent, Coffee…), and a group can teach the app to fill that category in
automatically by keyword. Both are managed from the **Labels** tab on the
group screen, next to Expenses / Balances / Members.

## Data model

`categories` (`supabase/migrations/0001_init.sql`, extended by `0012_categories.sql`):

| column | notes |
|---|---|
| `id` | uuid PK |
| `group_id` | **nullable** — null means a global default, seeded once and shared by every group |
| `name` | unique per group, case-insensitively (global rows are exempt) |
| `icon` | a slug into `kCategoryIcons` (`lib/core/icons.dart`), e.g. `'fork-knife'` |

`expenses.category_id`, `recurring_expenses.category_id`, and
`merchant_rules.category_id` all FK to it with `on delete set null` — deleting
a category never blocks or cascades, the rows that pointed at it just become
uncategorized.

RLS: `"members manage group categories"` (`for all`) lets a member
insert/update/delete rows scoped to their own group; a null `group_id` makes
`is_group_member(group_id)` evaluate to null (not true), so global defaults
are correctly read-only from the client. `CategoriesRepository`
(`lib/features/expenses/data/categories_repository.dart`) only ever writes
with an explicit `group_id` for this reason — there's no path to insert a
global row from the app.

## Tags are `merchant_rules`, not a separate table

A tag — *"COSTCO always means Groceries"* — is the exact shape the statement
importer already needed: a keyword, a match type (`contains` / `prefix` /
`exact`), a category, and a `share`/`skip` action. Rather than build a second
keyword→category table, tags **are** `merchant_rules` rows
(`supabase/migrations/0011_import.sql`); the Labels tab is a second UI over
the same data statement import has always used, and the two features have
been consistent by construction since the first migration. See
[`statement-import.md`](statement-import.md) for the matching rules
(`lib/features/import/logic/merchant_rules.dart`) and the case for keeping
regex out of it.

A tag now applies in two places:

- **Import review** — `matchMerchantRule`, unchanged: merchant name first,
then the statement's own category description as a weaker fallback.
- **The Add-expense form** — `matchTagCategory`, which runs the same
precedence (priority ascending, then longer pattern) against the typed
description as you type. It never fires once you've picked a category by
hand (`add_expense_screen.dart`'s `_categoryTouched` flag), and a
skip-action tag is ignored — "never offer during import" has no meaning for
a manual expense.

## Icons

`lib/core/icons.dart` is the app's one icon vocabulary, drawn from
[Phosphor](https://phosphoricons.com) (MIT) via `phosphor_flutter`:

- `kCategoryIcons` — the curated slug → `IconData` map the category picker
offers. Add to it (never remove a key a stored row might reference) when a
new use case needs an icon the picker doesn't have yet.
- `iconForCategory(slug)` — resolves a category's stored `icon` slug,
including the pre-migration Material names (`'restaurant'`, `'home'`, …)
via `_legacyIconAliases`, so a row written before `0012_categories.sql`
still renders instead of falling back to the generic tag icon.
- `categoryTint(id, brightness)` / `onCategoryTint(tint)` — a deterministic
HSL tint derived from the category's id, so every category avatar gets a
distinct, theme-appropriate colour with no colour picker or `color` column.
- `AppIcons` — semantic constants for app chrome (add, edit, delete, …), so
the rest of the app never reaches for `PhosphorIconsRegular.*` or
`Icons.*` directly.

Always use the static-const accessors (`PhosphorIconsRegular.house`), never
the `PhosphorIcons.regular.house` getter form — only the const form survives
`flutter build --tree-shake-icons`.

## Files

```
lib/core/icons.dart icon vocabulary, tints
lib/features/expenses/data/categories_repository.dart category CRUD
lib/features/expenses/providers/categories_provider.dart
lib/features/import/logic/merchant_rules.dart matchMerchantRule, matchTagCategory
lib/features/import/data/merchant_rules_repository.dart tag CRUD
lib/features/labels/ui/
labels_tab.dart the two sections, embedded as the group's 4th tab
labels_screen.dart standalone route (old /import/rules redirects here)
category_dialog.dart name + icon picker
tag_dialog.dart keyword + category, match type behind "Advanced"
supabase/migrations/0012_categories.sql
test/features/import/merchant_rules_test.dart matchMerchantRule, matchTagCategory
test/features/labels/labels_tab_test.dart
```
23 changes: 18 additions & 5 deletions docs/statement-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,15 @@ which sounds clever and has no data behind it.
Date-only values stay date-only. Never `.toLocal()` a statement date or compare
it against `DateTime.now()`; see the note in `lib/core/dates.dart`.

## Merchant rules
## Merchant rules, a.k.a. tags

A rule decides two things for a matched merchant: which category tags it, and
whether it is offered for sharing at all (`share` / `skip`).
A rule — user-facing name **tag**, table name still `merchant_rules` —
decides two things for a matched merchant: which category tags it, and
whether it is offered for sharing at all (`share` / `skip`). Tags are managed
from the **Labels** tab on the group screen (`lib/features/labels/`), not a
screen under Import any more; `/import/rules` redirects there for anyone with
the old link. See [`categories-and-tags.md`](categories-and-tags.md) for the
full data model and why tags and merchant rules are the same table.

Matching is substring-based on a normalized name, because merchant names carry
store and terminal noise — `COSTCO WHOLESALE W515` and `W521` are the same
Expand All @@ -164,6 +169,12 @@ the same merchant the same way instead of each person re-tagging every month.
Regex is deliberately not supported: one roommate's bad pattern would break
everyone's import.

The same rules also drive `matchTagCategory`, which the Add-expense form calls
as you type a description — a manual expense gets the same autofill a
statement import does, without the category-description fallback pass (there's
no second text source to fall back to). A skip-action tag is ignored there: it
only means "never offer during import".

## Adding PDF support (phase 2)

The pipeline is already behind an interface:
Expand Down Expand Up @@ -195,10 +206,12 @@ lib/features/import/
statement_parser, fingerprint, merchant_rules, import_plan
data/ import_repository, merchant_rules_repository
providers/ merchantRulesProvider, importedFingerprintsProvider
ui/ import_screen, import_review_list, merchant_rules_screen
ui/ import_screen, import_review_list
lib/features/labels/ui/ labels_tab, labels_screen, category_dialog, tag_dialog
lib/core/dates.dart
supabase/migrations/0011_import.sql
supabase/migrations/0011_import.sql, 0012_categories.sql
test/features/import/
test/features/labels/
```

Everything in `logic/` imports neither Supabase nor Flutter, which is what
Expand Down
172 changes: 172 additions & 0 deletions lib/core/icons.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart';

// The app's single icon vocabulary. Category icons are looked up by the slug
// stored in categories.icon; app chrome uses the AppIcons constants below.
// Everything here comes from PhosphorIconsRegular's static-const accessors —
// never PhosphorIcons.regular.xyz, which is a getter and defeats
// --tree-shake-icons.

// Curated category icons, grouped by theme. Add to this map (never remove a
// key still referenced by a stored category row) when a new use case needs an
// icon the picker doesn't already offer.
const Map<String, IconData> kCategoryIcons = {
// Food & drink
'fork-knife': PhosphorIconsRegular.forkKnife,
'shopping-cart': PhosphorIconsRegular.shoppingCart,
'coffee': PhosphorIconsRegular.coffee,
'pizza': PhosphorIconsRegular.pizza,
'wine': PhosphorIconsRegular.wine,
'cooking-pot': PhosphorIconsRegular.cookingPot,
'popcorn': PhosphorIconsRegular.popcorn,

// Housing
'house': PhosphorIconsRegular.house,
'house-line': PhosphorIconsRegular.houseLine,
'couch': PhosphorIconsRegular.couch,
'lightbulb': PhosphorIconsRegular.lightbulb,
'drop': PhosphorIconsRegular.drop,
'broom': PhosphorIconsRegular.broom,
'wrench': PhosphorIconsRegular.wrench,
'hammer': PhosphorIconsRegular.hammer,

// Transportation
'car': PhosphorIconsRegular.car,
'car-simple': PhosphorIconsRegular.carSimple,
'gas-pump': PhosphorIconsRegular.gasPump,
'bus': PhosphorIconsRegular.bus,
'train': PhosphorIconsRegular.train,
'taxi': PhosphorIconsRegular.taxi,
'bicycle': PhosphorIconsRegular.bicycle,
'scooter': PhosphorIconsRegular.scooter,
'motorcycle': PhosphorIconsRegular.motorcycle,

// Entertainment & leisure
'film-slate': PhosphorIconsRegular.filmSlate,
'music-notes': PhosphorIconsRegular.musicNotes,
'game-controller': PhosphorIconsRegular.gameController,
'ticket': PhosphorIconsRegular.ticket,
'barbell': PhosphorIconsRegular.barbell,
'soccer-ball': PhosphorIconsRegular.soccerBall,
'basketball': PhosphorIconsRegular.basketball,
'palette': PhosphorIconsRegular.palette,
'flower-lotus': PhosphorIconsRegular.flowerLotus,

// Health
'heartbeat': PhosphorIconsRegular.heartbeat,
'stethoscope': PhosphorIconsRegular.stethoscope,
'pill': PhosphorIconsRegular.pill,
'first-aid': PhosphorIconsRegular.firstAid,
'tooth': PhosphorIconsRegular.tooth,

// Travel
'airplane-tilt': PhosphorIconsRegular.airplaneTilt,
'suitcase': PhosphorIconsRegular.briefcase,
'umbrella': PhosphorIconsRegular.umbrella,

// Shopping
'shopping-bag-open': PhosphorIconsRegular.shoppingBagOpen,
't-shirt': PhosphorIconsRegular.tShirt,
'gift': PhosphorIconsRegular.gift,

// Bills & finance
'lightning': PhosphorIconsRegular.lightning,
'phone': PhosphorIconsRegular.phone,
'bank': PhosphorIconsRegular.bank,
'credit-card': PhosphorIconsRegular.creditCard,
'wallet': PhosphorIconsRegular.wallet,
'currency-circle-dollar': PhosphorIconsRegular.currencyCircleDollar,

// People & misc
'graduation-cap': PhosphorIconsRegular.graduationCap,
'baby': PhosphorIconsRegular.baby,
'dog': PhosphorIconsRegular.dog,
'cat': PhosphorIconsRegular.cat,
'briefcase': PhosphorIconsRegular.briefcase,
'dots-three-outline': PhosphorIconsRegular.dotsThreeOutline,
'tag': PhosphorIconsRegular.tag,
};

// Material icon-name slugs seeded by the original 0001 migration. Migration
// 0012 rewrites the seeded rows to Phosphor slugs, but a group category
// created before that migration ran (or a stale cached row) may still carry
// one of these — resolve it to its Phosphor equivalent rather than falling
// back to the generic tag icon.
const Map<String, String> _legacyIconAliases = {
'restaurant': 'fork-knife',
'shopping_cart': 'shopping-cart',
'home': 'house',
'directions_car': 'car',
'movie': 'film-slate',
'favorite': 'heartbeat',
'flight': 'airplane-tilt',
'shopping_bag': 'shopping-bag-open',
'bolt': 'lightning',
'more_horiz': 'dots-three-outline',
'label': 'tag',
};

// Resolves a category's stored icon slug to a Phosphor glyph. Unknown slugs
// (including a blank default) fall back to the generic tag icon rather than
// throwing, since the slug is free-form data from the database.
IconData iconForCategory(String slug) {
final resolved = kCategoryIcons[slug] ?? kCategoryIcons[_legacyIconAliases[slug]];
return resolved ?? PhosphorIconsRegular.tag;
}

// A deterministic tonal background for a category avatar, derived from its
// id so the same category always renders the same colour without needing a
// colour column or picker. Hue spread keeps adjacent categories visually
// distinct; saturation/lightness stay theme-appropriate.
Color categoryTint(String categoryId, Brightness brightness) {
final hue = (categoryId.hashCode % 360).abs().toDouble();
return HSLColor.fromAHSL(
1,
hue,
0.55,
brightness == Brightness.dark ? 0.26 : 0.85,
).toColor();
}

// A readable foreground colour for content drawn on top of [categoryTint].
Color onCategoryTint(Color tint) =>
ThemeData.estimateBrightnessForColor(tint) == Brightness.dark
? Colors.white
: Colors.black87;

// Semantic icon constants for app chrome (app bars, buttons, empty states),
// so the whole app draws from one icon vocabulary instead of scattering
// PhosphorIconsRegular.* literals through the UI.
abstract final class AppIcons {
const AppIcons._();

static const add = PhosphorIconsRegular.plus;
static const edit = PhosphorIconsRegular.pencilSimple;
static const delete = PhosphorIconsRegular.trash;
static const close = PhosphorIconsRegular.x;
static const info = PhosphorIconsRegular.info;
static const success = PhosphorIconsRegular.checkCircle;
static const arrowForward = PhosphorIconsRegular.arrowRight;
static const signIn = PhosphorIconsRegular.signIn;
static const groupAdd = PhosphorIconsRegular.usersFour;
static const personAdd = PhosphorIconsRegular.userPlus;
static const repeat = PhosphorIconsRegular.repeat;
static const insights = PhosphorIconsRegular.chartBar;
static const upload = PhosphorIconsRegular.uploadSimple;
static const settings = PhosphorIconsRegular.gearSix;
static const receipt = PhosphorIconsRegular.receipt;
static const key = PhosphorIconsRegular.key;
static const copy = PhosphorIconsRegular.copy;
static const share = PhosphorIconsRegular.shareNetwork;
static const email = PhosphorIconsRegular.envelopeSimple;
static const doneAll = PhosphorIconsRegular.checks;
static const tag = PhosphorIconsRegular.tag;
static const bookmarkAdd = PhosphorIconsRegular.bookmarkSimple;
static const filterOff = PhosphorIconsRegular.funnelX;
static const downloadDone = PhosphorIconsRegular.cloudCheck;
static const dateRange = PhosphorIconsRegular.calendarBlank;
static const block = PhosphorIconsRegular.prohibit;
static const camera = PhosphorIconsRegular.camera;
static const linkOff = PhosphorIconsRegular.linkBreak;
static const search = PhosphorIconsRegular.magnifyingGlass;
}
Loading
Loading