Files
OnBudget/CLAUDE.md
T
SandersandClaude Opus 4.8 fe4165a97b Update CLAUDE.md to match current codebase
- Add notification_parsing feature to directory map; analytics no longer placeholder
- Fix schemaVersion 2 -> 6
- Update data model: transactions note->merchant + parsing/habit fields, settings.habitTrackingEnabled, accounts.isDefault, parsing tables
- Drop completed 'wire FAB' item from What's-left and renumber

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:21:25 +03:00

11 KiB

NewBudget — Flutter personal finance app

Commands

flutter pub get
dart run build_runner build --delete-conflicting-outputs   # after changing @riverpod / @DriftDatabase / @freezed / ARB
flutter gen-l10n                                           # regenerate localizations (also runs with pub get)
flutter analyze
flutter run                                               # Android emulator
flutter test
flutter test test/path/to/file_test.dart                  # single test file (no -p flag — flutter test rejects it)

Run build_runner whenever you touch any .dart file that has @riverpod, @DriftDatabase, @freezed, or @JsonSerializable annotations, or after editing lib/l10n/*.arb.

Stack

Concern Library
State flutter_riverpod + riverpod_annotation + riverpod_generator (code-gen)
Database drift + drift_flutter (SQLite, reactive streams)
Navigation go_router v17 — StatefulShellRoute.indexedStack (4 bottom-tab branches)
Entities freezed_annotation (immutable, copyWith, ==)
Localization flutter_localizations + ARB → flutter gen-l10nlib/l10n/
Charts fl_chart
Fonts google_fonts

NOT used: shared_preferences, riverpod_lint/custom_lint (intentionally omitted).

Architecture (feature-first, layered — do not break)

presentation → application → domain ← data
   (UI)          (Riverpod)   (pure Dart)  (Drift impl)
  • presentation — Widgets only. Uses ref.watch(...), calls controller methods. No Drift imports.
  • application@riverpod Notifier/AsyncNotifier controllers. Validation, orchestration, UI state. Depends only on domain abstractions.
  • domain — pure-Dart entities + abstract repository interfaces. Zero Flutter/Drift deps.
  • data — Drift tables, DAOs, mappers (row ↔ entity), repository implementations. Only layer that touches SQL.

No use-case classes — controllers call repositories directly.

Key directory map

lib/
  main.dart                                  # runApp(ProviderScope(child: NewBudgetApp()))
  l10n/                                      # generated: app_localizations*.dart
  src/
    app/
      app.dart                               # MaterialApp.router, theme, locale
      l10n/l10n.dart                         # context.l10n extension
      router/app_router.dart                 # GoRouter + StatefulShellRoute (4 tabs)
      router/app_routes.dart                 # route path constants
      theme/app_theme.dart                   # light/dark ThemeData
      theme/app_colors.dart                  # Palette extension (paper/ink/line/accent/positive/negative)
      theme/theme_mode_controller.dart       # @Riverpod(keepAlive) ThemeMode — in-memory for now
    core/
      database/app_database.dart             # @DriftDatabase, schemaVersion=6
      database/tables/                       # users / app_preferences / settings / accounts / categories / transactions
      database/daos/                         # *_dao.dart with .watch*() methods
      database/converters/enum_converters.dart  # TypeConverter + re-exports all enums (UI imports enums from here)
      providers/database_provider.dart       # @Riverpod(keepAlive) AppDatabase
      money/money.dart                       # amounts stored as int minor units (kopecks/cents)
    features/
      user/         # incl. presentation/screens/onboarding_screen.dart + UserSeeder
      settings/     # domain + data + application ready; presentation empty
      accounts/     # accounts_screen, account_form_screen, currency/icon pickers, account_icon.dart
      categories/   # categories_list_screen, category_form_screen, icon/color pickers, category_icon.dart
      transactions/ # transaction_form_screen + draft state + account/category picker sheets
      home/presentation/
        screens/home_screen.dart             # ConsumerWidget, assembles widgets below
        widgets/                             # MonthHeader, AccountTabs, MonthKpiCard, CategoryDonutCard,
                                             #   TransactionsSection, DayHeader, TxRow, MoneyText, FabAddTransaction
        state/selected_category_filter.dart  # account/category filter providers (+ kAllAccountsId)
        month_summary.dart                   # client-side aggregates for KPI / donut
      analytics/   # analytics_screen + habit_analysis (providers/screen, /analytics/habits)
      notification_parsing/  # SMS/notification → transaction parsing (rules + AI/OpenRouter).
                             #   domain/data/application/presentation; Drift tables + DAOs;
                             #   data/parser/ (dedup, confidence, decision_gate, ai_parser),
                             #   data/openrouter/ (client, prompts, schema), ParsingWorker.
                             #   Screens: inbox, rules_list, rule_editor, parsing_settings, ai_consent, parsing_log
      profile/     # theme switcher screen
    shared/
      widgets/app_scaffold.dart              # StatefulShellRoute wrapper + AppBottomNav
      formatters/                            # EMPTY — planned intl money/date formatters

Data model

All amounts: int minor units. All IDs: String UUID v4 (client-generated, cloud-sync ready). Every domain table has a userId FK → users.

Table Key fields
users id, name, createdAt
app_preferences key (PK), value — stores active_user_id
settings userId FK, baseCurrency, themeMode(enum), locale, firstDayOfMonth, habitTrackingEnabled
accounts id, userId, name, type(enum), currency, initialBalance(int), iconCode, colorValue, archived, isDefault
categories id, userId, name, type(enum), iconCode, colorValue, parentId(nullable), archived
transactions id, userId, accountId, categoryId(nullable), type(enum), amount(int), date, merchant(nullable, was note), extraInfo(nullable), transferToAccountId(nullable), obligation/impulse(habit enums, nullable), rawMessageId/autoApplied/appliedByRuleId (parsing), createdAt

Notification-parsing tables (in features/notification_parsing/data/drift/): raw_messages, parse_rules, rule_candidates, account_bindings, transfer_pairing_blocklist. Their enums live in notification_parsing/domain/enums.dart; converters in .../data/drift/converters.dart (both imported by app_database.dart).

Enums live alongside their Drift tables; enum_converters.dart is the single import point for UI.

Code-gen gotchas

  • Generated files: *.g.dart (Riverpod/Drift/JSON), *.freezed.dart. Both are excluded from analysis but must be committed.
  • After any schema change to @DriftDatabase or table files, re-run build_runner and bump schemaVersion in app_database.dart.
  • Riverpod @riverpod providers generate into the same *.g.dart — don't split provider + its generated file across separate part directives in unexpected ways.

Localization

ARB files in lib/l10n/app_en.arb and lib/l10n/app_ru.arb.
Access strings via context.l10n.someKey (extension from src/app/l10n/l10n.dart).
After editing ARB files run flutter gen-l10n (or flutter pub get).

Theme / colors

Use Theme.of(context).extension<Palette>()! for brand colors.
Palette tokens: paper, ink, line, accent, positive, negative.
Do not use hard-coded color constants in widgets.

Active user

Active profile is stored in app_preferences (key active_user_id), read via activeUserControllerProvider. appRouter has a redirect callback + refreshListenable on this provider: until a user exists, all routes redirect to /onboarding; after usersController.createUser, the redirect clears and UserSeeder.seedForNewUser(userId) seeds default accounts, categories, and demo transactions (the demo seed is temporary — remove once add-transaction UX is finished).

Icon/color helpers that used to live in _mock_data.dart now live with their features: features/categories/presentation/widgets/category_icon.dart (iconForCategory, colorForCategory) and features/accounts/presentation/widgets/account_icon.dart (iconForAccount, shortAccountLabel).

Testing

  • No mocktail/mockito. Use custom fakes: FakeXxxRepository implements XxxRepository tracks calls + supports error/Completer gates; FakeXxxController extends XxxController overrides build().
  • Unit tests: ProviderContainer(overrides: [repo.overrideWithValue(fake)]). Widget tests: ProviderScope(overrides: [...], child: MaterialApp(...)).
  • Real-DB tests use AppDatabase.forTesting(NativeDatabase.memory()); seed FK chain (user → account → category) before inserting transactions.
  • Import conflict: package:drift/drift.dart exports isNull/isNotNull which clash with package:matcher. Use import 'package:drift/drift.dart' hide isNull, isNotNull;.
  • Controller errors: use try/catch + rethrow (see UsersController). Do NOT use AsyncValue.guard(...).value! — in Riverpod 3.x AsyncError.value is null, so ! throws TypeError instead of the real error.
  • Snackbar finders are ambiguous when the same text appears elsewhere; scope with find.descendant(of: find.byType(SnackBar), matching: find.text('...')).
  • Access the container inside a widget test via ProviderScope.containerOf(tester.element(find.byType(MyScreen))) to manipulate notifier state after pumpWidget.
  • ParsingWorker won't drain on container.read alone. Its input pendingMessagesProvider is autoDispose and only subscribes to the Drift stream when it has a direct listener (in-app that's HomeScreen). In a bare ProviderContainer add container.listen(pendingMessagesProvider(userId), (_, _) {}, fireImmediately: true) alongside reading the worker, or pending messages stay stuck at pending.
  • Integration tests hitting real OpenRouter live in test/features/notification_parsing/integration/, tagged @Tags(['integration']). Run with flutter test ... --tags integration --dart-define=OPENROUTER_API_KEY=sk-or-... (key never hardcoded; tests skip: when it's absent so default flutter test stays green/offline). Override aiKeyStoreProvider with a fake key store + isOnlineProvider with Stream.value(true) (connectivity_plus has no binding under flutter test); assert the terminal RawMessageStatus and decode draftJson via decodeDraftBundle. AI-sourced drafts never auto-apply (amount confidence ≤ 60 < strictness 85) — expect inbox.

What's left (priority order)

  1. Analytics screen — fl_chart over transaction streams (analytics_screen + habit_analysis exist; expand charts)
  2. Remove demo transactions from UserSeeder once add-transaction flow is solid
  3. Persist theme/locale via settingsController (replace in-memory themeModeController); make MaterialApp.router read locale from settings
  4. Transfer transactions: fix balance aggregation (case transfer: break; in month_summary.dart)
  5. shared/formatters/ — intl money + date formatters; migrate MoneyText + day grouping
  6. Expand test coverage (currently: transactions repo/controller/form, users controller, onboarding)

Open decisions (discuss before implementing)

  • Transfer model: single record with transferToAccountId vs paired income+expense records
  • Aggregates: client-side Provider (current) vs SQL watchTotalsByCategory(period) in DAO
  • riverpod_lint/custom_lint: re-add or keep omitted