diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 1e499a9..c36f2c8 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -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 diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 7da7793..51b7b88 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b5d9252..81c596e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/README.md b/README.md index 9540c6f..a839e13 100644 --- a/README.md +++ b/README.md @@ -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/ @@ -234,6 +236,7 @@ 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/ @@ -241,6 +244,7 @@ supabase/ 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 ``` diff --git a/docs/categories-and-tags.md b/docs/categories-and-tags.md new file mode 100644 index 0000000..aa4ec5d --- /dev/null +++ b/docs/categories-and-tags.md @@ -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 +``` diff --git a/docs/statement-import.md b/docs/statement-import.md index c54ca93..fd08b68 100644 --- a/docs/statement-import.md +++ b/docs/statement-import.md @@ -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 @@ -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: @@ -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 diff --git a/lib/core/icons.dart b/lib/core/icons.dart new file mode 100644 index 0000000..7579d6c --- /dev/null +++ b/lib/core/icons.dart @@ -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 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 _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; +} diff --git a/lib/core/router.dart b/lib/core/router.dart index b6fc89b..7a16c60 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -6,8 +6,8 @@ import 'package:tally/features/groups/ui/group_settings_screen.dart'; import 'package:tally/features/groups/ui/groups_list_screen.dart'; import 'package:tally/features/groups/ui/join_by_link_screen.dart'; import 'package:tally/features/import/ui/import_screen.dart'; -import 'package:tally/features/import/ui/merchant_rules_screen.dart'; import 'package:tally/features/insights/ui/insights_screen.dart'; +import 'package:tally/features/labels/ui/labels_screen.dart'; import 'package:tally/features/recurring/ui/add_recurring_screen.dart'; import 'package:tally/features/recurring/ui/recurring_list_screen.dart'; import 'package:flutter/material.dart'; @@ -95,14 +95,21 @@ final routerProvider = Provider((ref) { groupId: state.pathParameters['groupId']!, ), routes: [ + // Rules became tags and moved onto the Labels tab; keep the + // old path alive as a redirect for anyone with it bookmarked. GoRoute( path: 'rules', - builder: (_, state) => MerchantRulesScreen( - groupId: state.pathParameters['groupId']!, - ), + redirect: (_, state) => + '/groups/${state.pathParameters['groupId']}/labels', ), ], ), + GoRoute( + path: 'labels', + builder: (_, state) => LabelsScreen( + groupId: state.pathParameters['groupId']!, + ), + ), ], ), ], diff --git a/lib/features/auth/ui/login_screen.dart b/lib/features/auth/ui/login_screen.dart index d54d3b3..bf527d9 100644 --- a/lib/features/auth/ui/login_screen.dart +++ b/lib/features/auth/ui/login_screen.dart @@ -1,8 +1,8 @@ +import 'package:tally/core/icons.dart'; import 'package:tally/core/supabase_client.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; - class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -85,7 +85,7 @@ class _LoginScreenState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Icon( - Icons.receipt_long, + AppIcons.receipt, size: 72, color: theme.colorScheme.primary, ), @@ -127,7 +127,7 @@ class _LoginScreenState extends State { decoration: const InputDecoration( labelText: 'Email address', border: OutlineInputBorder(), - prefixIcon: Icon(Icons.email_outlined), + prefixIcon: Icon(AppIcons.email), ), ), const SizedBox(height: 16), diff --git a/lib/features/balances/ui/balances_tab.dart b/lib/features/balances/ui/balances_tab.dart index c1dd797..6eb6745 100644 --- a/lib/features/balances/ui/balances_tab.dart +++ b/lib/features/balances/ui/balances_tab.dart @@ -1,4 +1,5 @@ import 'package:decimal/decimal.dart'; +import 'package:tally/core/icons.dart'; import 'package:tally/core/money.dart'; import 'package:tally/features/balances/data/balances_repository.dart'; import 'package:tally/features/balances/logic/settle.dart'; @@ -8,7 +9,6 @@ import 'package:tally/features/groups/providers/groups_provider.dart'; import 'package:tally/models/group_member.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; - class BalancesTab extends ConsumerWidget { const BalancesTab({super.key, required this.groupId}); final String groupId; @@ -36,7 +36,7 @@ class BalancesTab extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.check_circle_outline, size: 64, color: Colors.green), + Icon(AppIcons.success, size: 64, color: Colors.green), SizedBox(height: 16), Text('All settled up', style: TextStyle(fontSize: 18)), ], @@ -174,7 +174,7 @@ class _SettlementRowState extends ConsumerState<_SettlementRow> { final t = widget.transfer; return Card( child: ListTile( - leading: const Icon(Icons.arrow_forward), + leading: const Icon(AppIcons.arrowForward), title: Text('${widget.fromName} → ${widget.toName}'), subtitle: Text(formatCurrency(t.amount)), trailing: _saving diff --git a/lib/features/expenses/data/categories_repository.dart b/lib/features/expenses/data/categories_repository.dart index 0630c3c..2e2d48b 100644 --- a/lib/features/expenses/data/categories_repository.dart +++ b/lib/features/expenses/data/categories_repository.dart @@ -13,4 +13,36 @@ class CategoriesRepository { .map((e) => Category.fromJson(e as Map)) .toList(); } + + // group_id is required here — a null group_id is a global default, which + // "members manage group categories" (0001_init.sql) deliberately rejects + // from the client. + Future createCategory({ + required String groupId, + required String name, + required String icon, + }) async { + await supabase.from('categories').insert({ + 'group_id': groupId, + 'name': name.trim(), + 'icon': icon, + }); + } + + Future updateCategory({ + required String id, + required String name, + required String icon, + }) async { + await supabase + .from('categories') + .update({'name': name.trim(), 'icon': icon}).eq('id', id); + } + + // Any expense, recurring template or tag pointing at this category falls + // back to uncategorized (on delete set null, migration 0012) rather than + // blocking the delete. + Future deleteCategory({required String id}) async { + await supabase.from('categories').delete().eq('id', id); + } } diff --git a/lib/features/expenses/providers/categories_provider.dart b/lib/features/expenses/providers/categories_provider.dart index 8e6cd25..9e3ff20 100644 --- a/lib/features/expenses/providers/categories_provider.dart +++ b/lib/features/expenses/providers/categories_provider.dart @@ -1,37 +1,9 @@ import 'package:tally/features/expenses/data/categories_repository.dart'; import 'package:tally/models/category.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; // Categories available for a group (global defaults + group-specific). +// Invalidate after createCategory/updateCategory/deleteCategory. final categoriesProvider = FutureProvider.family, String>( (ref, groupId) => CategoriesRepository().fetchCategories(groupId: groupId), ); - -// Map the DB icon-name hint to a Material icon (const, tree-shake friendly). -IconData iconForCategory(String name) { - switch (name) { - case 'restaurant': - return Icons.restaurant; - case 'shopping_cart': - return Icons.shopping_cart; - case 'home': - return Icons.home; - case 'directions_car': - return Icons.directions_car; - case 'movie': - return Icons.movie; - case 'favorite': - return Icons.favorite; - case 'flight': - return Icons.flight; - case 'shopping_bag': - return Icons.shopping_bag; - case 'bolt': - return Icons.bolt; - case 'more_horiz': - return Icons.more_horiz; - default: - return Icons.label; - } -} diff --git a/lib/features/expenses/ui/add_expense_screen.dart b/lib/features/expenses/ui/add_expense_screen.dart index 49b80e8..7f9f176 100644 --- a/lib/features/expenses/ui/add_expense_screen.dart +++ b/lib/features/expenses/ui/add_expense_screen.dart @@ -1,4 +1,5 @@ import 'package:decimal/decimal.dart'; +import 'package:tally/core/icons.dart'; import 'package:tally/core/supabase_client.dart'; import 'package:tally/core/widgets/page_body.dart'; import 'package:tally/features/expenses/data/expenses_repository.dart'; @@ -8,10 +9,13 @@ import 'package:tally/features/expenses/providers/expenses_provider.dart'; import 'package:tally/features/expenses/split_logic.dart'; import 'package:tally/features/expenses/ui/split_editor.dart'; import 'package:tally/features/groups/providers/groups_provider.dart'; +import 'package:tally/features/import/logic/merchant_rules.dart'; +import 'package:tally/features/import/providers/import_providers.dart'; import 'package:tally/models/category.dart'; import 'package:tally/models/expense.dart'; import 'package:tally/models/expense_split.dart'; import 'package:tally/models/group_member.dart'; +import 'package:tally/models/merchant_rule.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -42,6 +46,10 @@ class _AddExpenseScreenState extends ConsumerState { final Set _selected = {}; String? _payerMemberId; String? _categoryId; + // Once the user has picked a category by hand, a tag match on the + // description must never silently override it. + bool _categoryTouched = false; + bool _autoTagged = false; // drives the "Tagged automatically" hint bool _inited = false; bool _submitting = false; @@ -66,6 +74,22 @@ class _AddExpenseScreenState extends ConsumerState { TextEditingController _percent(String id) => _percentCtl.putIfAbsent(id, () => TextEditingController()); + // Fills in the category from the group's tags as the description is typed, + // the same matching a statement import uses (longer pattern wins, skip + // rules ignored). Never fires once the user has picked a category by hand. + void _applyTag(List rules) { + if (_categoryTouched) return; + final categoryId = matchTagCategory( + text: _descriptionController.text, + rules: rules, + ); + if (categoryId == null || categoryId == _categoryId) return; + setState(() { + _categoryId = categoryId; + _autoTagged = true; + }); + } + SplitOutcome _compute(List members) => computeSplits( splitType: _splitType, orderedMemberIds: members.map((m) => m.id).toList(), @@ -95,6 +119,8 @@ class _AddExpenseScreenState extends ConsumerState { _amountController.text = e.amount.toString(); _descriptionController.text = e.description; _categoryId = e.categoryId; + // Never let a tag rewrite a category that already exists on the expense. + _categoryTouched = true; _payerMemberId = e.payerMemberId; _splitType = e.splitType; _selected @@ -214,6 +240,9 @@ class _AddExpenseScreenState extends ConsumerState { Widget _form(List members) { final categoriesAsync = ref.watch(categoriesProvider(widget.groupId)); + final rules = + ref.watch(merchantRulesProvider(widget.groupId)).valueOrNull ?? + const []; final compute = _compute(members); return Form( @@ -251,13 +280,28 @@ class _AddExpenseScreenState extends ConsumerState { hintText: 'e.g. Dinner, Groceries…', border: OutlineInputBorder(), ), + onChanged: (_) => _applyTag(rules), ), const SizedBox(height: 16), _CategoryDropdown( categoriesAsync: categoriesAsync, value: _categoryId, - onChanged: (v) => setState(() => _categoryId = v), + onChanged: (v) => setState(() { + _categoryId = v; + _categoryTouched = true; + _autoTagged = false; + }), ), + if (_autoTagged) + Padding( + padding: const EdgeInsets.only(top: 4, left: 4), + child: Text( + 'Tagged automatically from your tags — tap to change.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), const SizedBox(height: 16), DropdownButtonFormField( initialValue: _payerMemberId, @@ -306,7 +350,7 @@ class _AddExpenseScreenState extends ConsumerState { Row( children: [ Icon( - compute.valid ? Icons.check_circle : Icons.info_outline, + compute.valid ? AppIcons.success : AppIcons.info, size: 18, color: compute.valid ? Colors.green diff --git a/lib/features/groups/ui/group_detail_screen.dart b/lib/features/groups/ui/group_detail_screen.dart index a3fb7f5..36877ad 100644 --- a/lib/features/groups/ui/group_detail_screen.dart +++ b/lib/features/groups/ui/group_detail_screen.dart @@ -1,13 +1,17 @@ +import 'package:tally/core/icons.dart'; import 'package:tally/core/money.dart'; import 'package:tally/core/supabase_client.dart'; import 'package:tally/core/widgets/page_body.dart'; import 'package:tally/features/balances/providers/balances_provider.dart'; import 'package:tally/features/balances/ui/balances_tab.dart'; import 'package:tally/features/expenses/data/expenses_repository.dart'; +import 'package:tally/features/expenses/providers/categories_provider.dart'; import 'package:tally/features/expenses/providers/expenses_provider.dart'; import 'package:tally/features/groups/data/groups_repository.dart'; import 'package:tally/features/groups/providers/groups_provider.dart'; +import 'package:tally/features/labels/ui/labels_tab.dart'; import 'package:tally/features/realtime/group_realtime_provider.dart'; +import 'package:tally/models/category.dart'; import 'package:tally/models/expense.dart'; import 'package:tally/models/group_member.dart'; import 'package:flutter/foundation.dart' show kIsWeb; @@ -28,7 +32,7 @@ class GroupDetailScreen extends ConsumerStatefulWidget { class _GroupDetailScreenState extends ConsumerState with SingleTickerProviderStateMixin { late final TabController _tab = - TabController(length: 3, vsync: this)..addListener(() => setState(() {})); + TabController(length: 4, vsync: this)..addListener(() => setState(() {})); @override void dispose() { @@ -73,22 +77,22 @@ class _GroupDetailScreenState extends ConsumerState actions: [ IconButton( tooltip: 'Recurring', - icon: const Icon(Icons.repeat), + icon: const Icon(AppIcons.repeat), onPressed: () => context.push('/groups/${widget.groupId}/recurring'), ), IconButton( tooltip: 'Insights', - icon: const Icon(Icons.bar_chart), + icon: const Icon(AppIcons.insights), onPressed: () => context.push('/groups/${widget.groupId}/insights'), ), IconButton( tooltip: 'Import statement', - icon: const Icon(Icons.upload_file), + icon: const Icon(AppIcons.upload), onPressed: _importStatement, ), IconButton( tooltip: 'Group settings', - icon: const Icon(Icons.settings_outlined), + icon: const Icon(AppIcons.settings), onPressed: () => context.push('/groups/${widget.groupId}/settings'), ), ], @@ -98,18 +102,19 @@ class _GroupDetailScreenState extends ConsumerState Tab(text: 'Expenses'), Tab(text: 'Balances'), Tab(text: 'Members'), + Tab(text: 'Labels'), ], ), ), floatingActionButton: switch (_tab.index) { 0 => FloatingActionButton.extended( onPressed: _addExpense, - icon: const Icon(Icons.add), + icon: const Icon(AppIcons.add), label: const Text('Add expense'), ), 2 => FloatingActionButton.extended( onPressed: _addGuest, - icon: const Icon(Icons.person_add), + icon: const Icon(AppIcons.personAdd), label: const Text('Add guest'), ), _ => null, @@ -121,6 +126,7 @@ class _GroupDetailScreenState extends ConsumerState _ExpensesTab(groupId: widget.groupId), BalancesTab(groupId: widget.groupId), _MembersTab(groupId: widget.groupId), + LabelsTab(groupId: widget.groupId), ], ), ), @@ -137,12 +143,18 @@ class _ExpensesTab extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final expensesAsync = ref.watch(expensesProvider(groupId)); + // A slow categories fetch never blocks the expense list — cards just + // render uncategorized until it resolves. + final categories = ref.watch(categoriesProvider(groupId)).valueOrNull ?? + const []; + final categoryById = {for (final c in categories) c.id: c}; + return expensesAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Error: $e')), data: (expenses) => expenses.isEmpty ? const _EmptyState( - icon: Icons.receipt_long, + icon: AppIcons.receipt, title: 'No expenses yet', subtitle: 'Tap + to log the first one.', ) @@ -150,17 +162,25 @@ class _ExpensesTab extends ConsumerWidget { padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), itemCount: expenses.length, separatorBuilder: (_, __) => const SizedBox(height: 8), - itemBuilder: (_, i) => - _ExpenseCard(expense: expenses[i], groupId: groupId), + itemBuilder: (_, i) => _ExpenseCard( + expense: expenses[i], + groupId: groupId, + category: categoryById[expenses[i].categoryId], + ), ), ); } } class _ExpenseCard extends ConsumerWidget { - const _ExpenseCard({required this.expense, required this.groupId}); + const _ExpenseCard({ + required this.expense, + required this.groupId, + required this.category, + }); final Expense expense; final String groupId; + final Category? category; void _refresh(WidgetRef ref) { ref.invalidate(expensesProvider(groupId)); @@ -211,7 +231,7 @@ class _ExpenseCard extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ ListTile( - leading: const Icon(Icons.edit_outlined), + leading: const Icon(AppIcons.edit), title: const Text('Edit'), onTap: () { Navigator.pop(sheetCtx); @@ -219,7 +239,7 @@ class _ExpenseCard extends ConsumerWidget { }, ), ListTile( - leading: const Icon(Icons.delete_outline), + leading: const Icon(AppIcons.delete), title: const Text('Delete'), onTap: () { Navigator.pop(sheetCtx); @@ -237,24 +257,37 @@ class _ExpenseCard extends ConsumerWidget { final theme = Theme.of(context); final dateStr = DateFormat('MMM d').format(expense.occurredOn); final amountStr = formatCurrency(expense.amount, currency: expense.currency); + final category = this.category; + + // Uncategorized expenses keep the original neutral look; a category + // gets its own icon on a deterministic tint (see categoryTint). + final Color tint; + final Color onTint; + final IconData icon; + if (category == null) { + tint = theme.colorScheme.secondaryContainer; + onTint = theme.colorScheme.onSecondaryContainer; + icon = AppIcons.receipt; + } else { + tint = categoryTint(category.id, theme.brightness); + onTint = onCategoryTint(tint); + icon = iconForCategory(category.icon); + } return Card( clipBehavior: Clip.hardEdge, child: ListTile( onTap: () => _showActions(context, ref), leading: CircleAvatar( - backgroundColor: theme.colorScheme.secondaryContainer, - child: Icon( - Icons.receipt_outlined, - color: theme.colorScheme.onSecondaryContainer, - ), + backgroundColor: tint, + child: Icon(icon, color: onTint), ), title: Text( expense.description.isEmpty ? 'Expense' : expense.description, style: theme.textTheme.titleSmall, ), subtitle: Text( - dateStr, + category == null ? dateStr : '$dateStr · ${category.name}', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -320,7 +353,7 @@ class _JoinCodeCard extends StatelessWidget { padding: const EdgeInsets.all(16), child: Row( children: [ - Icon(Icons.key, color: theme.colorScheme.onPrimaryContainer), + Icon(AppIcons.key, color: theme.colorScheme.onPrimaryContainer), const SizedBox(width: 12), Expanded( child: Column( @@ -343,7 +376,7 @@ class _JoinCodeCard extends StatelessWidget { ), IconButton( tooltip: 'Copy code', - icon: Icon(Icons.copy, color: theme.colorScheme.onPrimaryContainer), + icon: Icon(AppIcons.copy, color: theme.colorScheme.onPrimaryContainer), onPressed: () { Clipboard.setData(ClipboardData(text: code)); ScaffoldMessenger.of(context).showSnackBar( @@ -353,7 +386,7 @@ class _JoinCodeCard extends StatelessWidget { ), IconButton( tooltip: 'Share invite', - icon: Icon(Icons.ios_share, + icon: Icon(AppIcons.share, color: theme.colorScheme.onPrimaryContainer), onPressed: () { // On web, share a one-tap link that opens straight to the join diff --git a/lib/features/groups/ui/group_settings_screen.dart b/lib/features/groups/ui/group_settings_screen.dart index a7a6977..4c407da 100644 --- a/lib/features/groups/ui/group_settings_screen.dart +++ b/lib/features/groups/ui/group_settings_screen.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; +import 'package:tally/core/icons.dart'; import 'package:tally/core/widgets/page_body.dart'; import 'package:tally/features/groups/data/groups_repository.dart'; import 'package:tally/features/groups/providers/groups_provider.dart'; @@ -95,7 +96,7 @@ class _GroupSettingsScreenState extends ConsumerState { child: CircleAvatar( radius: 18, backgroundColor: theme.colorScheme.primary, - child: Icon(Icons.camera_alt, + child: Icon(AppIcons.camera, size: 18, color: theme.colorScheme.onPrimary), ), ), diff --git a/lib/features/groups/ui/groups_list_screen.dart b/lib/features/groups/ui/groups_list_screen.dart index 7894872..8d23baf 100644 --- a/lib/features/groups/ui/groups_list_screen.dart +++ b/lib/features/groups/ui/groups_list_screen.dart @@ -1,3 +1,4 @@ +import 'package:tally/core/icons.dart'; import 'package:tally/core/money.dart'; import 'package:tally/core/widgets/page_body.dart'; import 'package:tally/features/groups/data/groups_repository.dart'; @@ -8,7 +9,6 @@ import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; - class GroupsListScreen extends ConsumerWidget { const GroupsListScreen({super.key}); @@ -23,7 +23,7 @@ class GroupsListScreen extends ConsumerWidget { actions: [ IconButton( tooltip: 'Join by code', - icon: const Icon(Icons.login), + icon: const Icon(AppIcons.signIn), onPressed: () => showDialog( context: context, builder: (_) => const JoinGroupDialog(), @@ -41,7 +41,7 @@ class GroupsListScreen extends ConsumerWidget { }, ), ), - icon: const Icon(Icons.add), + icon: const Icon(AppIcons.add), label: const Text('New group'), ), body: PageBody( @@ -210,7 +210,7 @@ class _EmptyState extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.group_add, size: 64, color: theme.colorScheme.outlineVariant), + Icon(AppIcons.groupAdd, size: 64, color: theme.colorScheme.outlineVariant), const SizedBox(height: 16), Text('No groups yet', style: theme.textTheme.titleMedium), const SizedBox(height: 8), diff --git a/lib/features/groups/ui/join_by_link_screen.dart b/lib/features/groups/ui/join_by_link_screen.dart index 0c8538f..85ff154 100644 --- a/lib/features/groups/ui/join_by_link_screen.dart +++ b/lib/features/groups/ui/join_by_link_screen.dart @@ -1,9 +1,9 @@ +import 'package:tally/core/icons.dart'; import 'package:tally/features/groups/data/groups_repository.dart'; import 'package:tally/features/groups/providers/groups_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; - // Landing screen for an invite link (/join?code=XXXXXX). The auth redirect in // the router guarantees the user is signed in by the time they reach here, so // we can join straight away and drop them into the group. @@ -52,7 +52,7 @@ class _JoinByLinkScreenState extends ConsumerState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.link_off, + Icon(AppIcons.linkOff, size: 56, color: Theme.of(context).colorScheme.error), const SizedBox(height: 16), diff --git a/lib/features/import/logic/merchant_rules.dart b/lib/features/import/logic/merchant_rules.dart index c669668..0c0c00c 100644 --- a/lib/features/import/logic/merchant_rules.dart +++ b/lib/features/import/logic/merchant_rules.dart @@ -86,3 +86,23 @@ RuleHit matchMerchantRule({ // and silently hiding a transaction is worse than showing it untagged. return const RuleHit(action: 'share', status: 'Untagged'); } + +// The category a manually typed expense description should fall into, from +// the group's tags — same matching rules as import (priority, then longer +// pattern wins), but simpler: there's no separate "category description" +// fallback pass, since a manual description is the only text available. A +// skip-action tag (used to silence a merchant during import) has no bearing +// here and is ignored, so it never blocks a manual category autofill. +String? matchTagCategory({ + required String text, + required List rules, +}) { + final haystack = text.trim().toUpperCase(); + if (haystack.isEmpty) return null; + + for (final rule in _ordered(rules)) { + if (rule.isSkip) continue; + if (_matches(rule, haystack)) return rule.categoryId; + } + return null; +} diff --git a/lib/features/import/ui/import_review_list.dart b/lib/features/import/ui/import_review_list.dart index 8dfcf62..6896dce 100644 --- a/lib/features/import/ui/import_review_list.dart +++ b/lib/features/import/ui/import_review_list.dart @@ -1,6 +1,6 @@ import 'package:tally/core/dates.dart'; +import 'package:tally/core/icons.dart'; import 'package:tally/core/money.dart'; -import 'package:tally/features/expenses/providers/categories_provider.dart'; import 'package:tally/features/import/logic/import_plan.dart'; import 'package:tally/models/category.dart'; import 'package:flutter/material.dart'; @@ -107,12 +107,12 @@ class _TransactionCard extends StatelessWidget { style: theme.textTheme.bodySmall, ), if (blocked) - const _Chip(label: 'Already imported', icon: Icons.done_all) + const _Chip(label: 'Already imported', icon: AppIcons.doneAll) else ActionChip( avatar: Icon( category == null - ? Icons.label_outline + ? AppIcons.tag : iconForCategory(category!.icon), size: 16, ), @@ -136,7 +136,7 @@ class _TransactionCard extends StatelessWidget { if (!blocked) IconButton( tooltip: 'Always tag this merchant', - icon: const Icon(Icons.bookmark_add_outlined), + icon: const Icon(AppIcons.bookmarkAdd), onPressed: onAlwaysTag, ), ], @@ -190,7 +190,7 @@ class _EmptyRows extends StatelessWidget { child: Column( children: [ Icon( - Icons.filter_alt_off, + AppIcons.filterOff, size: 64, color: theme.colorScheme.outlineVariant, ), diff --git a/lib/features/import/ui/import_screen.dart b/lib/features/import/ui/import_screen.dart index 0933498..3042990 100644 --- a/lib/features/import/ui/import_screen.dart +++ b/lib/features/import/ui/import_screen.dart @@ -1,6 +1,7 @@ import 'package:decimal/decimal.dart'; import 'package:file_picker/file_picker.dart'; import 'package:tally/core/dates.dart'; +import 'package:tally/core/icons.dart'; import 'package:tally/core/supabase_client.dart'; import 'package:tally/core/widgets/page_body.dart'; import 'package:tally/features/expenses/providers/categories_provider.dart'; @@ -20,7 +21,6 @@ import 'package:tally/models/import_result.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; - enum _Step { pick, review, done } class ImportScreen extends ConsumerStatefulWidget { @@ -358,10 +358,10 @@ class _ImportScreenState extends ConsumerState { title: const Text('Import statement'), actions: [ IconButton( - tooltip: 'Merchant rules', - icon: const Icon(Icons.rule), + tooltip: 'Tags', + icon: const Icon(AppIcons.tag), onPressed: () => - context.push('/groups/${widget.groupId}/import/rules'), + context.push('/groups/${widget.groupId}/labels'), ), ], ), @@ -507,7 +507,7 @@ class _ImportScreenState extends ConsumerState { Row( children: [ Icon( - plan.valid ? Icons.check_circle : Icons.info_outline, + plan.valid ? AppIcons.success : AppIcons.info, size: 18, color: plan.valid ? Colors.green : theme.colorScheme.error, ), @@ -528,7 +528,7 @@ class _ImportScreenState extends ConsumerState { width: 20, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Icon(Icons.download_done), + : const Icon(AppIcons.downloadDone), label: Text('Import $selectedCount'), ), ), @@ -568,7 +568,7 @@ class _PickStep extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Icon( - Icons.receipt_long, + AppIcons.receipt, size: 56, color: theme.colorScheme.primary, ), @@ -593,7 +593,7 @@ class _PickStep extends StatelessWidget { width: 20, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Icon(Icons.upload_file), + : const Icon(AppIcons.upload), label: const Text('Choose CSV file'), ), ], @@ -626,14 +626,14 @@ class _RangeBar extends StatelessWidget { Expanded( child: OutlinedButton.icon( onPressed: onPick, - icon: const Icon(Icons.date_range), + icon: const Icon(AppIcons.dateRange), label: Text(label), ), ), if (range != null) IconButton( tooltip: 'Clear date filter', - icon: const Icon(Icons.close), + icon: const Icon(AppIcons.close), onPressed: onClear, ), ], @@ -659,7 +659,7 @@ class _DoneStep extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.check_circle, + Icon(AppIcons.success, size: 56, color: theme.colorScheme.primary), const SizedBox(height: 16), Text(r?.summary ?? 'Imported', diff --git a/lib/features/import/ui/merchant_rules_screen.dart b/lib/features/import/ui/merchant_rules_screen.dart deleted file mode 100644 index 52b35f5..0000000 --- a/lib/features/import/ui/merchant_rules_screen.dart +++ /dev/null @@ -1,302 +0,0 @@ -import 'package:tally/core/widgets/page_body.dart'; -import 'package:tally/features/expenses/providers/categories_provider.dart'; -import 'package:tally/features/import/data/merchant_rules_repository.dart'; -import 'package:tally/features/import/providers/import_providers.dart'; -import 'package:tally/models/category.dart'; -import 'package:tally/models/merchant_rule.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -// The group's tagging presets. Shared rather than per-device so everyone on a -// shared card tags the same merchant the same way. -class MerchantRulesScreen extends ConsumerStatefulWidget { - const MerchantRulesScreen({super.key, required this.groupId}); - - final String groupId; - - @override - ConsumerState createState() => - _MerchantRulesScreenState(); -} - -class _MerchantRulesScreenState extends ConsumerState { - Future _edit({MerchantRule? existing}) async { - final categories = - ref.read(categoriesProvider(widget.groupId)).valueOrNull ?? - const []; - - final draft = await showDialog( - context: context, - builder: (_) => RuleDialog(categories: categories, existing: existing), - ); - if (draft == null) return; - - try { - final repo = MerchantRulesRepository(); - if (existing == null) { - await repo.createRule( - groupId: widget.groupId, - pattern: draft.pattern, - matchType: draft.matchType, - action: draft.action, - categoryId: draft.categoryId, - priority: draft.priority, - ); - } else { - await repo.updateRule( - id: existing.id, - pattern: draft.pattern, - matchType: draft.matchType, - action: draft.action, - categoryId: draft.categoryId, - priority: draft.priority, - ); - } - ref.invalidate(merchantRulesProvider(widget.groupId)); - } on Exception catch (e) { - if (mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(e.toString()))); - } - } - } - - Future _delete(MerchantRule rule) async { - final ok = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Delete rule?'), - content: Text('${rule.pattern} will no longer be tagged automatically.'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Delete'), - ), - ], - ), - ); - if (ok != true) return; - - try { - await MerchantRulesRepository().deleteRule(id: rule.id); - ref.invalidate(merchantRulesProvider(widget.groupId)); - } on Exception catch (e) { - if (mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(e.toString()))); - } - } - } - - @override - Widget build(BuildContext context) { - final rulesAsync = ref.watch(merchantRulesProvider(widget.groupId)); - final categories = - ref.watch(categoriesProvider(widget.groupId)).valueOrNull ?? - const []; - final nameOf = {for (final c in categories) c.id: c}; - - return Scaffold( - appBar: AppBar(title: const Text('Merchant rules')), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => _edit(), - icon: const Icon(Icons.add), - label: const Text('New rule'), - ), - body: PageBody( - child: rulesAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('Error: $e')), - data: (rules) { - if (rules.isEmpty) return const _EmptyRules(); - return ListView.separated( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), - itemCount: rules.length, - separatorBuilder: (_, __) => const SizedBox(height: 8), - itemBuilder: (_, i) { - final rule = rules[i]; - final category = nameOf[rule.categoryId]; - return Card( - clipBehavior: Clip.hardEdge, - child: ListTile( - leading: CircleAvatar( - child: Icon( - rule.isSkip - ? Icons.block - : iconForCategory(category?.icon ?? 'label'), - ), - ), - title: Text(rule.pattern), - subtitle: Text( - rule.isSkip - ? '${rule.matchType} · never shared' - : '${rule.matchType} · ${category?.name ?? 'no category'}', - ), - trailing: IconButton( - tooltip: 'Delete', - icon: const Icon(Icons.delete_outline), - onPressed: () => _delete(rule), - ), - onTap: () => _edit(existing: rule), - ), - ); - }, - ); - }, - ), - ), - ); - } -} - -class RuleDraft { - const RuleDraft({ - required this.pattern, - required this.matchType, - required this.action, - required this.priority, - this.categoryId, - }); - - final String pattern; - final String matchType; - final String action; - final int priority; - final String? categoryId; -} - -class RuleDialog extends StatefulWidget { - const RuleDialog({super.key, required this.categories, this.existing}); - - final List categories; - final MerchantRule? existing; - - @override - State createState() => _RuleDialogState(); -} - -class _RuleDialogState extends State { - late final _pattern = - TextEditingController(text: widget.existing?.pattern ?? ''); - late String _matchType = widget.existing?.matchType ?? 'contains'; - late String? _categoryId = widget.existing?.categoryId; - late bool _share = !(widget.existing?.isSkip ?? false); - - @override - void dispose() { - _pattern.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Text(widget.existing == null ? 'New rule' : 'Edit rule'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: _pattern, - autofocus: true, - textCapitalization: TextCapitalization.characters, - decoration: const InputDecoration( - labelText: 'Merchant pattern', - helperText: 'e.g. COSTCO WHOLESALE', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 12), - SegmentedButton( - segments: const [ - ButtonSegment(value: 'contains', label: Text('Contains')), - ButtonSegment(value: 'prefix', label: Text('Starts')), - ButtonSegment(value: 'exact', label: Text('Exact')), - ], - selected: {_matchType}, - onSelectionChanged: (s) => setState(() => _matchType = s.first), - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: _categoryId, - decoration: const InputDecoration( - labelText: 'Category', - border: OutlineInputBorder(), - ), - items: [ - for (final c in widget.categories) - DropdownMenuItem(value: c.id, child: Text(c.name)), - ], - onChanged: _share ? (v) => setState(() => _categoryId = v) : null, - ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - value: _share, - onChanged: (v) => setState(() => _share = v), - title: const Text('Share with the group'), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - final pattern = _pattern.text.trim(); - if (pattern.isEmpty) return; - Navigator.pop( - context, - RuleDraft( - pattern: pattern, - matchType: _matchType, - action: _share ? 'share' : 'skip', - priority: widget.existing?.priority ?? 100, - categoryId: _share ? _categoryId : null, - ), - ); - }, - child: const Text('Save'), - ), - ], - ); - } -} - -class _EmptyRules extends StatelessWidget { - const _EmptyRules(); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.rule, size: 64, color: theme.colorScheme.outlineVariant), - const SizedBox(height: 12), - Text('No rules yet', style: theme.textTheme.titleMedium), - const SizedBox(height: 4), - Text( - 'Rules tag imported transactions automatically, for everyone in ' - 'the group. Add one here, or from a transaction while reviewing ' - 'a statement.', - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/insights/ui/insights_screen.dart b/lib/features/insights/ui/insights_screen.dart index f74c0b5..b6640fe 100644 --- a/lib/features/insights/ui/insights_screen.dart +++ b/lib/features/insights/ui/insights_screen.dart @@ -1,13 +1,12 @@ import 'package:decimal/decimal.dart'; +import 'package:tally/core/icons.dart'; import 'package:tally/core/money.dart'; import 'package:tally/core/widgets/page_body.dart'; -import 'package:tally/features/expenses/providers/categories_provider.dart'; import 'package:tally/features/insights/data/insights_repository.dart'; import 'package:tally/features/insights/providers/insights_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; - class InsightsScreen extends ConsumerWidget { const InsightsScreen({super.key, required this.groupId}); final String groupId; diff --git a/lib/features/labels/ui/category_dialog.dart b/lib/features/labels/ui/category_dialog.dart new file mode 100644 index 0000000..5dca456 --- /dev/null +++ b/lib/features/labels/ui/category_dialog.dart @@ -0,0 +1,137 @@ +import 'package:tally/core/icons.dart'; +import 'package:tally/models/category.dart'; +import 'package:flutter/material.dart'; + +class CategoryDraft { + const CategoryDraft({required this.name, required this.icon}); + final String name; + final String icon; +} + +// Create/edit a group category: a name plus one icon picked from +// kCategoryIcons. Global (group_id == null) categories never reach this +// dialog — the Labels tab renders them read-only. +class CategoryDialog extends StatefulWidget { + const CategoryDialog({super.key, this.existing}); + final Category? existing; + + @override + State createState() => _CategoryDialogState(); +} + +class _CategoryDialogState extends State { + late final _name = TextEditingController(text: widget.existing?.name ?? ''); + late String _icon = widget.existing?.icon ?? kCategoryIcons.keys.first; + final _search = TextEditingController(); + + @override + void dispose() { + _name.dispose(); + _search.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final query = _search.text.trim().toLowerCase(); + final entries = kCategoryIcons.entries + .where((e) => query.isEmpty || e.key.contains(query)) + .toList(); + final tint = categoryTint(_icon, theme.brightness); + + return AlertDialog( + title: Text(widget.existing == null ? 'New category' : 'Edit category'), + content: SizedBox( + width: 360, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + CircleAvatar( + backgroundColor: tint, + child: Icon(iconForCategory(_icon), color: onCategoryTint(tint)), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _name, + autofocus: true, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + labelText: 'Category name', + hintText: 'e.g. Coffee', + border: OutlineInputBorder(), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + TextField( + controller: _search, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'Search icons', + prefixIcon: Icon(AppIcons.search), + border: OutlineInputBorder(), + isDense: true, + ), + ), + const SizedBox(height: 12), + SizedBox( + height: 220, + child: entries.isEmpty + ? const Center(child: Text('No icons match')) + : GridView.builder( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + ), + itemCount: entries.length, + itemBuilder: (_, i) { + final slug = entries[i].key; + final selected = slug == _icon; + return InkWell( + borderRadius: BorderRadius.circular(24), + onTap: () => setState(() => _icon = slug), + child: CircleAvatar( + backgroundColor: selected + ? theme.colorScheme.primary + : theme.colorScheme.surfaceContainerHighest, + child: Icon( + entries[i].value, + color: selected + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurfaceVariant, + ), + ), + ); + }, + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final name = _name.text.trim(); + if (name.isEmpty) return; + Navigator.pop(context, CategoryDraft(name: name, icon: _icon)); + }, + child: const Text('Save'), + ), + ], + ); + } +} diff --git a/lib/features/labels/ui/labels_screen.dart b/lib/features/labels/ui/labels_screen.dart new file mode 100644 index 0000000..daa960e --- /dev/null +++ b/lib/features/labels/ui/labels_screen.dart @@ -0,0 +1,19 @@ +import 'package:tally/core/widgets/page_body.dart'; +import 'package:tally/features/labels/ui/labels_tab.dart'; +import 'package:flutter/material.dart'; + +// Standalone route for Labels, so a bookmarked /import/rules link (retired +// alongside MerchantRulesScreen) has somewhere to land. The group detail +// screen embeds LabelsTab directly rather than pushing this route. +class LabelsScreen extends StatelessWidget { + const LabelsScreen({super.key, required this.groupId}); + final String groupId; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Labels')), + body: PageBody(child: LabelsTab(groupId: groupId)), + ); + } +} diff --git a/lib/features/labels/ui/labels_tab.dart b/lib/features/labels/ui/labels_tab.dart new file mode 100644 index 0000000..419cc37 --- /dev/null +++ b/lib/features/labels/ui/labels_tab.dart @@ -0,0 +1,399 @@ +import 'package:tally/core/icons.dart'; +import 'package:tally/features/expenses/data/categories_repository.dart'; +import 'package:tally/features/expenses/providers/categories_provider.dart'; +import 'package:tally/features/import/data/merchant_rules_repository.dart'; +import 'package:tally/features/import/providers/import_providers.dart'; +import 'package:tally/features/labels/ui/category_dialog.dart'; +import 'package:tally/features/labels/ui/tag_dialog.dart'; +import 'package:tally/models/category.dart'; +import 'package:tally/models/merchant_rule.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +// Categories (icon + name, one per expense) and tags (a keyword that +// auto-assigns a category on import and on the Add-expense form) live +// together here — a tag is meaningless without the category it points at. +class LabelsTab extends ConsumerWidget { + const LabelsTab({super.key, required this.groupId}); + final String groupId; + + Future _editCategory( + BuildContext context, + WidgetRef ref, { + Category? existing, + }) async { + final draft = await showDialog( + context: context, + builder: (_) => CategoryDialog(existing: existing), + ); + if (draft == null) return; + + try { + final repo = CategoriesRepository(); + if (existing == null) { + await repo.createCategory( + groupId: groupId, name: draft.name, icon: draft.icon); + } else { + await repo.updateCategory( + id: existing.id, name: draft.name, icon: draft.icon); + } + ref.invalidate(categoriesProvider(groupId)); + } on Exception catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.toString()))); + } + } + } + + Future _deleteCategory( + BuildContext context, WidgetRef ref, Category category) async { + final ok = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Delete category?'), + content: Text( + '${category.name} will be removed. Expenses using it become ' + 'uncategorized.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Delete'), + ), + ], + ), + ); + if (ok != true) return; + + try { + await CategoriesRepository().deleteCategory(id: category.id); + ref.invalidate(categoriesProvider(groupId)); + } on Exception catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.toString()))); + } + } + } + + Future _editTag( + BuildContext context, + WidgetRef ref, + List categories, { + MerchantRule? existing, + }) async { + final draft = await showDialog( + context: context, + builder: (_) => TagDialog(categories: categories, existing: existing), + ); + if (draft == null) return; + + try { + final repo = MerchantRulesRepository(); + if (existing == null) { + await repo.createRule( + groupId: groupId, + pattern: draft.pattern, + matchType: draft.matchType, + action: draft.action, + categoryId: draft.categoryId, + priority: draft.priority, + ); + } else { + await repo.updateRule( + id: existing.id, + pattern: draft.pattern, + matchType: draft.matchType, + action: draft.action, + categoryId: draft.categoryId, + priority: draft.priority, + ); + } + ref.invalidate(merchantRulesProvider(groupId)); + } on Exception catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.toString()))); + } + } + } + + Future _deleteTag( + BuildContext context, WidgetRef ref, MerchantRule rule) async { + final ok = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Delete tag?'), + content: Text('${rule.pattern} will no longer be tagged automatically.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Delete'), + ), + ], + ), + ); + if (ok != true) return; + + try { + await MerchantRulesRepository().deleteRule(id: rule.id); + ref.invalidate(merchantRulesProvider(groupId)); + } on Exception catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.toString()))); + } + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final categoriesAsync = ref.watch(categoriesProvider(groupId)); + final rulesAsync = ref.watch(merchantRulesProvider(groupId)); + final categories = categoriesAsync.valueOrNull ?? const []; + + return ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), + children: [ + _SectionHeader( + title: 'Categories', + buttonLabel: 'New category', + onAdd: () => _editCategory(context, ref), + ), + const SizedBox(height: 8), + categoriesAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Text('Error: $e'), + data: (categories) => categories.isEmpty + ? const _EmptySection( + icon: AppIcons.tag, + message: 'No categories yet. Add one to start tagging expenses.', + ) + : Column( + children: [ + for (final c in categories) + _CategoryTile( + category: c, + onTap: c.groupId == null + ? null + : () => _editCategory(context, ref, existing: c), + onDelete: c.groupId == null + ? null + : () => _deleteCategory(context, ref, c), + ), + ], + ), + ), + const SizedBox(height: 24), + _SectionHeader( + title: 'Tags', + buttonLabel: 'New tag', + onAdd: categories.isEmpty + ? null + : () => _editTag(context, ref, categories), + ), + const SizedBox(height: 4), + Text( + 'A tag matches a keyword against a merchant or a typed description ' + "and fills in its category automatically — e.g. \"COSTCO\" → Groceries.", + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 8), + rulesAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Text('Error: $e'), + data: (rules) { + if (rules.isEmpty) { + return const _EmptySection( + icon: AppIcons.tag, + message: 'No tags yet.', + ); + } + final categoryById = {for (final c in categories) c.id: c}; + return Column( + children: [ + for (final rule in rules) + _TagTile( + rule: rule, + category: categoryById[rule.categoryId], + onTap: () => _editTag(context, ref, categories, existing: rule), + onDelete: () => _deleteTag(context, ref, rule), + ), + ], + ); + }, + ), + ], + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({ + required this.title, + required this.buttonLabel, + required this.onAdd, + }); + final String title; + final String buttonLabel; + final VoidCallback? onAdd; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium), + const Spacer(), + TextButton.icon( + onPressed: onAdd, + icon: const Icon(AppIcons.add, size: 18), + label: Text(buttonLabel), + ), + ], + ); + } +} + +class _CategoryTile extends StatelessWidget { + const _CategoryTile({ + required this.category, + required this.onTap, + required this.onDelete, + }); + final Category category; + final VoidCallback? onTap; + final VoidCallback? onDelete; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tint = categoryTint(category.id, theme.brightness); + return Card( + clipBehavior: Clip.hardEdge, + child: ListTile( + onTap: onTap, + leading: CircleAvatar( + backgroundColor: tint, + child: Icon(iconForCategory(category.icon), color: onCategoryTint(tint)), + ), + title: Text(category.name), + trailing: onDelete == null + ? const _Chip(label: 'Default') + : IconButton( + tooltip: 'Delete', + icon: const Icon(AppIcons.delete), + onPressed: onDelete, + ), + ), + ); + } +} + +class _TagTile extends StatelessWidget { + const _TagTile({ + required this.rule, + required this.category, + required this.onTap, + required this.onDelete, + }); + final MerchantRule rule; + final Category? category; + final VoidCallback onTap; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tint = category == null + ? theme.colorScheme.surfaceContainerHighest + : categoryTint(category!.id, theme.brightness); + return Card( + clipBehavior: Clip.hardEdge, + child: ListTile( + onTap: onTap, + leading: CircleAvatar( + backgroundColor: tint, + child: Icon( + rule.isSkip ? AppIcons.block : iconForCategory(category?.icon ?? 'tag'), + color: category == null + ? theme.colorScheme.onSurfaceVariant + : onCategoryTint(tint), + ), + ), + title: Text(rule.pattern), + subtitle: Text( + rule.isSkip + ? '${rule.matchType} · never shared' + : '${rule.matchType} · ${category?.name ?? 'no category'}', + ), + trailing: IconButton( + tooltip: 'Delete', + icon: const Icon(AppIcons.delete), + onPressed: onDelete, + ), + ), + ); + } +} + +class _EmptySection extends StatelessWidget { + const _EmptySection({required this.icon, required this.message}); + final IconData icon; + final String message; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Row( + children: [ + Icon(icon, color: theme.colorScheme.outlineVariant), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: theme.textTheme.bodyMedium + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ), + ], + ), + ); + } +} + +class _Chip extends StatelessWidget { + const _Chip({required this.label}); + final String label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: theme.colorScheme.outline.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.outline, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/lib/features/labels/ui/tag_dialog.dart b/lib/features/labels/ui/tag_dialog.dart new file mode 100644 index 0000000..8a2614b --- /dev/null +++ b/lib/features/labels/ui/tag_dialog.dart @@ -0,0 +1,135 @@ +import 'package:tally/models/category.dart'; +import 'package:tally/models/merchant_rule.dart'; +import 'package:flutter/material.dart'; + +// A tag is stored as a merchant_rules row: matching a keyword against either +// a statement merchant name (import) or a typed description (manual expense) +// assigns a category automatically. See matchMerchantRule / matchTagCategory +// in features/import/logic/merchant_rules.dart. +class RuleDraft { + const RuleDraft({ + required this.pattern, + required this.matchType, + required this.action, + required this.priority, + this.categoryId, + }); + + final String pattern; + final String matchType; + final String action; + final int priority; + final String? categoryId; +} + +class TagDialog extends StatefulWidget { + const TagDialog({super.key, required this.categories, this.existing}); + + final List categories; + final MerchantRule? existing; + + @override + State createState() => _TagDialogState(); +} + +class _TagDialogState extends State { + late final _pattern = + TextEditingController(text: widget.existing?.pattern ?? ''); + late String _matchType = widget.existing?.matchType ?? 'contains'; + late String? _categoryId = widget.existing?.categoryId; + late bool _share = !(widget.existing?.isSkip ?? false); + + @override + void dispose() { + _pattern.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.existing == null ? 'New tag' : 'Edit tag'), + content: SizedBox( + width: 360, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _pattern, + autofocus: true, + textCapitalization: TextCapitalization.characters, + decoration: const InputDecoration( + labelText: 'Keyword', + helperText: 'e.g. COSTCO — matched against merchant names ' + 'and expense descriptions', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _categoryId, + decoration: const InputDecoration( + labelText: 'Category', + border: OutlineInputBorder(), + ), + items: [ + for (final c in widget.categories) + DropdownMenuItem(value: c.id, child: Text(c.name)), + ], + onChanged: _share ? (v) => setState(() => _categoryId = v) : null, + ), + const SizedBox(height: 4), + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _share, + onChanged: (v) => setState(() => _share = v), + title: const Text('Share with the group'), + subtitle: const Text('Off = never offer this merchant during import'), + ), + ExpansionTile( + tilePadding: EdgeInsets.zero, + title: const Text('Advanced'), + childrenPadding: const EdgeInsets.only(bottom: 8), + children: [ + SegmentedButton( + segments: const [ + ButtonSegment(value: 'contains', label: Text('Contains')), + ButtonSegment(value: 'prefix', label: Text('Starts')), + ButtonSegment(value: 'exact', label: Text('Exact')), + ], + selected: {_matchType}, + onSelectionChanged: (s) => setState(() => _matchType = s.first), + ), + ], + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final pattern = _pattern.text.trim(); + if (pattern.isEmpty) return; + Navigator.pop( + context, + RuleDraft( + pattern: pattern, + matchType: _matchType, + action: _share ? 'share' : 'skip', + priority: widget.existing?.priority ?? 100, + categoryId: _share ? _categoryId : null, + ), + ); + }, + child: const Text('Save'), + ), + ], + ); + } +} diff --git a/lib/features/recurring/ui/add_recurring_screen.dart b/lib/features/recurring/ui/add_recurring_screen.dart index 448867e..4e62647 100644 --- a/lib/features/recurring/ui/add_recurring_screen.dart +++ b/lib/features/recurring/ui/add_recurring_screen.dart @@ -1,4 +1,5 @@ import 'package:decimal/decimal.dart'; +import 'package:tally/core/icons.dart'; import 'package:tally/core/supabase_client.dart'; import 'package:tally/core/widgets/page_body.dart'; import 'package:tally/features/expenses/providers/categories_provider.dart'; @@ -13,7 +14,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; - class AddRecurringScreen extends ConsumerStatefulWidget { const AddRecurringScreen({super.key, required this.groupId}); final String groupId; @@ -262,7 +262,7 @@ class _AddRecurringScreenState extends ConsumerState { const SizedBox(height: 16), ListTile( contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.event), + leading: const Icon(AppIcons.dateRange), title: const Text('Starts on'), trailing: Text(DateFormat('MMM d, yyyy').format(_startDate)), onTap: () async { @@ -309,7 +309,7 @@ class _AddRecurringScreenState extends ConsumerState { Row( children: [ Icon( - compute.valid ? Icons.check_circle : Icons.info_outline, + compute.valid ? AppIcons.success : AppIcons.info, size: 18, color: compute.valid ? Colors.green diff --git a/lib/features/recurring/ui/recurring_list_screen.dart b/lib/features/recurring/ui/recurring_list_screen.dart index 1cb21a3..bde663b 100644 --- a/lib/features/recurring/ui/recurring_list_screen.dart +++ b/lib/features/recurring/ui/recurring_list_screen.dart @@ -1,3 +1,4 @@ +import 'package:tally/core/icons.dart'; import 'package:tally/core/money.dart'; import 'package:tally/core/widgets/page_body.dart'; import 'package:tally/features/groups/providers/groups_provider.dart'; @@ -9,7 +10,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; - class RecurringListScreen extends ConsumerWidget { const RecurringListScreen({super.key, required this.groupId}); final String groupId; @@ -28,7 +28,7 @@ class RecurringListScreen extends ConsumerWidget { await context.push('/groups/$groupId/recurring/new'); ref.invalidate(recurringProvider(groupId)); }, - icon: const Icon(Icons.add), + icon: const Icon(AppIcons.add), label: const Text('New recurring'), ), body: PageBody( @@ -104,7 +104,7 @@ class _RecurringCard extends StatelessWidget { ), IconButton( tooltip: 'Delete', - icon: const Icon(Icons.delete_outline), + icon: const Icon(AppIcons.delete), onPressed: () async { final ok = await showDialog( context: context, @@ -147,7 +147,7 @@ class _EmptyState extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.repeat, size: 64, color: theme.colorScheme.outlineVariant), + Icon(AppIcons.repeat, size: 64, color: theme.colorScheme.outlineVariant), const SizedBox(height: 16), Text('No recurring expenses', style: theme.textTheme.titleMedium), const SizedBox(height: 8), diff --git a/pubspec.lock b/pubspec.lock index d4a55a9..8e4dc8f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -552,6 +552,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + phosphor_flutter: + dependency: "direct main" + description: + name: phosphor_flutter + sha256: "8a14f238f28a0b54842c5a4dc20676598dd4811fcba284ed828bd5a262c11fde" + url: "https://pub.dev" + source: hosted + version: "2.1.0" platform: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index dc6c2b9..d9dcbe8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,11 @@ dependencies: # dep of supabase_flutter so it can be imported directly. crypto: ^3.0.0 + # Icon set for the whole app, including category icons. Use the static-const + # accessors (PhosphorIconsRegular.house), not PhosphorIcons.regular.house — + # only the former is const, which --tree-shake-icons needs. + phosphor_flutter: ^2.1.0 + dev_dependencies: flutter_test: sdk: flutter diff --git a/supabase/migrations/0012_categories.sql b/supabase/migrations/0012_categories.sql new file mode 100644 index 0000000..2648170 --- /dev/null +++ b/supabase/migrations/0012_categories.sql @@ -0,0 +1,60 @@ +-- TALLY — category management +-- +-- Categories have existed since 0001 (a nullable group_id means "global +-- default"), and every expense/recurring template/merchant rule already +-- carries a category_id. What's missing is the app being able to touch them: +-- there was no delete-safe path for a category still in use, and nothing +-- stopped a group from creating two categories with the same name. +-- +-- This migration also re-seeds the 10 global categories with Phosphor icon +-- slugs (lib/core/icons.dart) — Tally's icon set moved off Material. + +-- ---- deletable categories -------------------------------------------- + +-- Each FK was declared with no ON DELETE action (the implicit NO ACTION), +-- so deleting a category still referenced by a row fails outright. Since a +-- category can now be deleted from the UI, an expense/template/rule that +-- pointed at it should simply fall back to "uncategorized" rather than block +-- the delete or cascade into losing data. +alter table expenses + drop constraint expenses_category_id_fkey, + add constraint expenses_category_id_fkey + foreign key (category_id) references categories(id) on delete set null; + +alter table recurring_expenses + drop constraint recurring_expenses_category_id_fkey, + add constraint recurring_expenses_category_id_fkey + foreign key (category_id) references categories(id) on delete set null; + +alter table merchant_rules + drop constraint merchant_rules_category_id_fkey, + add constraint merchant_rules_category_id_fkey + foreign key (category_id) references categories(id) on delete set null; + +-- ---- no duplicate names within a group --------------------------------- + +-- Global rows (group_id is null) are exempt — case-insensitive uniqueness +-- there is a seed-data concern, not something a group can trigger. +create unique index categories_group_name_idx + on categories (group_id, lower(name)) + where group_id is not null; + +-- ---- re-seed global categories with Phosphor slugs --------------------- + +alter table categories alter column icon set default 'tag'; + +update categories set icon = case icon + when 'restaurant' then 'fork-knife' + when 'shopping_cart' then 'shopping-cart' + when 'home' then 'house' + when 'directions_car' then 'car' + when 'movie' then 'film-slate' + when 'favorite' then 'heartbeat' + when 'flight' then 'airplane-tilt' + when 'shopping_bag' then 'shopping-bag-open' + when 'bolt' then 'lightning' + when 'more_horiz' then 'dots-three-outline' + when 'label' then 'tag' + else icon +end +where group_id is null; diff --git a/test/features/import/import_ui_test.dart b/test/features/import/import_ui_test.dart index 2450a2b..a65992e 100644 --- a/test/features/import/import_ui_test.dart +++ b/test/features/import/import_ui_test.dart @@ -6,7 +6,7 @@ import 'package:tally/features/import/logic/import_plan.dart'; import 'package:tally/features/import/providers/import_providers.dart'; import 'package:tally/features/import/ui/import_review_list.dart'; import 'package:tally/features/import/ui/import_screen.dart'; -import 'package:tally/features/import/ui/merchant_rules_screen.dart'; +import 'package:tally/features/labels/ui/labels_screen.dart'; import 'package:tally/models/category.dart'; import 'package:tally/models/group_member.dart'; import 'package:tally/models/merchant_rule.dart'; @@ -267,8 +267,7 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('offers the merchant rules screen from the app bar', - (tester) async { + testWidgets('offers the labels screen from the app bar', (tester) async { _desktop(tester); addTearDown(tester.view.reset); @@ -283,17 +282,17 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byTooltip('Merchant rules'), findsOneWidget); + expect(find.byTooltip('Tags'), findsOneWidget); }); }); - group('MerchantRulesScreen', () { - testWidgets('lists the group rules', (tester) async { + group('LabelsScreen', () { + testWidgets('lists the group categories and tags', (tester) async { _desktop(tester); addTearDown(tester.view.reset); await tester.pumpWidget( - _host(const MerchantRulesScreen(groupId: _gid), [ + _host(const LabelsScreen(groupId: _gid), [ categoriesProvider(_gid).overrideWith((ref) async => _categories), merchantRulesProvider(_gid).overrideWith((ref) async => _rules), ]), @@ -301,17 +300,16 @@ void main() { await tester.pumpAndSettle(); expect(find.text('COSTCO WHOLESALE'), findsOneWidget); - expect(find.textContaining('Groceries'), findsOneWidget); + expect(find.textContaining('Groceries'), findsWidgets); expect(tester.takeException(), isNull); }); - testWidgets('shows an empty state explaining rules are group-wide', - (tester) async { + testWidgets('shows an empty state when there are no tags', (tester) async { _desktop(tester); addTearDown(tester.view.reset); await tester.pumpWidget( - _host(const MerchantRulesScreen(groupId: _gid), [ + _host(const LabelsScreen(groupId: _gid), [ categoriesProvider(_gid).overrideWith((ref) async => _categories), merchantRulesProvider(_gid) .overrideWith((ref) async => []), @@ -319,8 +317,7 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('No rules yet'), findsOneWidget); - expect(find.textContaining('everyone in the group'), findsOneWidget); + expect(find.text('No tags yet.'), findsOneWidget); }); testWidgets('a skip rule reads as never shared', (tester) async { @@ -328,7 +325,7 @@ void main() { addTearDown(tester.view.reset); await tester.pumpWidget( - _host(const MerchantRulesScreen(groupId: _gid), [ + _host(const LabelsScreen(groupId: _gid), [ categoriesProvider(_gid).overrideWith((ref) async => _categories), merchantRulesProvider(_gid).overrideWith((ref) async => [ const MerchantRule( diff --git a/test/features/import/merchant_rules_test.dart b/test/features/import/merchant_rules_test.dart index f39292b..5692497 100644 --- a/test/features/import/merchant_rules_test.dart +++ b/test/features/import/merchant_rules_test.dart @@ -133,4 +133,50 @@ void main() { expect(match('COSTCO WHOLESALE W515').status, 'Untagged'); }); }); + + // matchTagCategory drives the Add-expense form's autofill: same tags, no + // category-description fallback pass (a typed description is all there is). + group('matchTagCategory', () { + test('matches a keyword anywhere in the description', () { + expect( + matchTagCategory(text: 'Costco run', rules: [rule('COSTCO')]), + 'groceries', + ); + }); + + test('on equal priority the longer pattern wins', () { + final rules = [ + rule('COSTCO', id: 'broad', categoryId: 'shopping'), + rule('COSTCO GAS', id: 'narrow', categoryId: 'transport'), + ]; + expect( + matchTagCategory(text: 'Costco gas fill-up', rules: rules), + 'transport', + ); + }); + + test('lower priority wins', () { + final rules = [ + rule('COSTCO', id: 'general', priority: 200, categoryId: 'shopping'), + rule('COSTCO', id: 'specific', priority: 10, categoryId: 'groceries'), + ]; + expect(matchTagCategory(text: 'Costco', rules: rules), 'groceries'); + }); + + test('a skip rule is ignored, never blocking a manual autofill', () { + final rules = [rule('COSTCO', action: 'skip', categoryId: null)]; + expect(matchTagCategory(text: 'Costco run', rules: rules), isNull); + }); + + test('no match returns null', () { + expect( + matchTagCategory(text: 'Dinner with friends', rules: [rule('COSTCO')]), + isNull, + ); + }); + + test('an empty description matches nothing', () { + expect(matchTagCategory(text: '', rules: [rule('COSTCO')]), isNull); + }); + }); } diff --git a/test/features/labels/labels_tab_test.dart b/test/features/labels/labels_tab_test.dart new file mode 100644 index 0000000..ffd7d17 --- /dev/null +++ b/test/features/labels/labels_tab_test.dart @@ -0,0 +1,116 @@ +import 'package:tally/core/theme.dart'; +import 'package:tally/features/expenses/providers/categories_provider.dart'; +import 'package:tally/features/import/providers/import_providers.dart'; +import 'package:tally/features/labels/ui/labels_tab.dart'; +import 'package:tally/models/category.dart'; +import 'package:tally/models/merchant_rule.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:phosphor_flutter/phosphor_flutter.dart'; + +import '../../support/test_supabase.dart'; + +const _gid = 'g1'; + +const _categories = [ + Category(id: 'global1', name: 'Groceries', icon: 'shopping-cart'), + Category(id: 'group1', name: 'Coffee', icon: 'coffee', groupId: _gid), +]; + +void _desktop(WidgetTester tester) { + tester.view.physicalSize = const Size(1600, 1000); + tester.view.devicePixelRatio = 1.0; +} + +Widget _host(List overrides) => ProviderScope( + overrides: overrides, + child: MaterialApp( + theme: AppTheme.light, + home: const Scaffold(body: LabelsTab(groupId: _gid)), + ), + ); + +void main() { + setUpAll(initTestSupabase); + + testWidgets('renders both a categories and a tags section', (tester) async { + _desktop(tester); + addTearDown(tester.view.reset); + + await tester.pumpWidget(_host([ + categoriesProvider(_gid).overrideWith((ref) async => _categories), + merchantRulesProvider(_gid).overrideWith((ref) async => [ + const MerchantRule( + id: 'r1', + groupId: _gid, + pattern: 'COSTCO', + matchType: 'contains', + action: 'share', + priority: 100, + categoryId: 'global1', + ), + ]), + ])); + await tester.pumpAndSettle(); + + expect(find.text('Categories'), findsOneWidget); + expect(find.text('Groceries'), findsOneWidget); + expect(find.text('Coffee'), findsOneWidget); + expect(find.text('Tags'), findsOneWidget); + expect(find.text('COSTCO'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('a global category shows a Default chip and no delete button', + (tester) async { + _desktop(tester); + addTearDown(tester.view.reset); + + await tester.pumpWidget(_host([ + categoriesProvider(_gid).overrideWith((ref) async => _categories), + merchantRulesProvider(_gid).overrideWith((ref) async => []), + ])); + await tester.pumpAndSettle(); + + expect(find.text('Default'), findsOneWidget); + // Two categories, one delete button — the global row has none. + expect(find.byIcon(PhosphorIconsRegular.trash), findsOneWidget); + }); + + testWidgets('empty states appear with no categories or tags', + (tester) async { + _desktop(tester); + addTearDown(tester.view.reset); + + await tester.pumpWidget(_host([ + categoriesProvider(_gid).overrideWith((ref) async => const []), + merchantRulesProvider(_gid).overrideWith((ref) async => []), + ])); + await tester.pumpAndSettle(); + + expect( + find.textContaining('No categories yet'), + findsOneWidget, + ); + expect(find.text('No tags yet.'), findsOneWidget); + }); + + testWidgets('deleting a group category opens a confirm dialog', + (tester) async { + _desktop(tester); + addTearDown(tester.view.reset); + + await tester.pumpWidget(_host([ + categoriesProvider(_gid).overrideWith((ref) async => _categories), + merchantRulesProvider(_gid).overrideWith((ref) async => []), + ])); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(PhosphorIconsRegular.trash)); + await tester.pumpAndSettle(); + + expect(find.text('Delete category?'), findsOneWidget); + expect(find.textContaining('Coffee'), findsWidgets); + }); +} diff --git a/test/features/screens_smoke_test.dart b/test/features/screens_smoke_test.dart index d6020b3..5bf0400 100644 --- a/test/features/screens_smoke_test.dart +++ b/test/features/screens_smoke_test.dart @@ -6,6 +6,7 @@ import 'package:tally/features/expenses/providers/expenses_provider.dart'; import 'package:tally/features/expenses/ui/add_expense_screen.dart'; import 'package:tally/features/groups/providers/groups_provider.dart'; import 'package:tally/features/groups/ui/group_detail_screen.dart'; +import 'package:tally/features/import/providers/import_providers.dart'; import 'package:tally/features/insights/data/insights_repository.dart'; import 'package:tally/features/insights/providers/insights_provider.dart'; import 'package:tally/features/insights/ui/insights_screen.dart'; @@ -174,8 +175,9 @@ void main() { [ membersProvider(_gid).overrideWith((ref) async => _members), categoriesProvider(_gid).overrideWith((ref) async => [ - const Category(id: 'c1', name: 'Food', icon: 'restaurant'), + const Category(id: 'c1', name: 'Food', icon: 'fork-knife'), ]), + merchantRulesProvider(_gid).overrideWith((ref) async => []), ], )); await tester.pumpAndSettle(); @@ -188,7 +190,7 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('GroupDetailScreen renders its three tabs', (tester) async { + testWidgets('GroupDetailScreen renders its four tabs', (tester) async { _desktop(tester); addTearDown(tester.view.reset); await tester.pumpWidget(_host( @@ -199,6 +201,10 @@ void main() { expensesProvider(_gid).overrideWith((ref) async => _expenses), balancesProvider(_gid).overrideWith((ref) async => _balances), groupRealtimeProvider(_gid).overrideWithValue(null), + categoriesProvider(_gid).overrideWith((ref) async => const [ + Category(id: 'c1', name: 'Food', icon: 'fork-knife'), + ]), + merchantRulesProvider(_gid).overrideWith((ref) async => []), ], )); await tester.pumpAndSettle(); @@ -206,9 +212,10 @@ void main() { expect(find.text('Expenses'), findsOneWidget); expect(find.text('Balances'), findsOneWidget); expect(find.text('Members'), findsOneWidget); + expect(find.text('Labels'), findsOneWidget); expect(find.text('Groceries'), findsOneWidget); - // Import is an app-bar action rather than a fourth tab, deliberately: the + // Import is an app-bar action rather than its own tab, deliberately: the // tab count above is what this test exists to pin. expect(find.byTooltip('Import statement'), findsOneWidget); @@ -220,6 +227,10 @@ void main() { await tester.pumpAndSettle(); expect(find.textContaining('Members ('), findsOneWidget); expect(find.text('AB452A'), findsOneWidget); + + await tester.tap(find.text('Labels')); + await tester.pumpAndSettle(); + expect(find.text('Food'), findsOneWidget); expect(tester.takeException(), isNull); }); }