Files
OnBudget/CLAUDE.md
T
2026-06-28 00:39:09 +03:00

13 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=8
      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/DeepSeek).
                             #   domain/data/application/presentation; Drift tables + DAOs;
                             #   data/parser/ (dedup, confidence, decision_gate, ai_parser),
                             #   data/deepseek/ (client, prompts), 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 (+txType — transaction type pinned at rule creation, checked by the gate), rule_candidates, account_bindings (+isDefault per-app default binding), source_apps (allowlist of monitored apps), transfer_pairing_blocklist. Their enums live in notification_parsing/domain/enums.dart; converters in .../data/drift/converters.dart (both imported by app_database.dart).

Auto-apply gate (data/parser/decision_gate.dart): no numeric confidence threshold — a checklist of named AutoApplyChecks (rule matched, amount literally found in body, currency known, draft type == rule txType (null = skip), account resolved+trusted, amount ≤ 100 000 ₽), gated by the autoApplyEnabled settings toggle. Failed checks are cached in draftJson (DraftBundle.failedChecks) and shown as "why not automatic" in the inbox card / parsing log. Numeric per-field scores remain only for the "?" badge in Inbox.

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 and categories. Demo transactions are not auto-seeded — they're added only via the manual "seed demo" button in profile_screen (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. Drafts for merchants WITHOUT a user rule never auto-apply (gate check ruleMatched fails) — expect inbox; with a rule + amount present in the body + trusted account the gate auto-applies.

What's left (priority order)

  1. Analytics screen — still a PlaceholderScreen hub (only the habit-analysis tile); fl_chart is not used anywhere in analytics/ yet. Build charts over transaction streams.
  2. Persist theme via settingsController: themeModeController is still in-memory (build() => ThemeMode.dark) and app.dart reads it, not settings. The settings.themeMode column exists but is unused. Mirror AppLocaleController (locale is already persisted).
  3. Fully remove demo-transaction code from UserSeeder once the add-transaction flow is solid. Auto-seeding on user creation is already gone (seedForNewUser only seeds accounts
    • categories); what remains is the manual path — seedDemoTransactionsForUser / _seedDemoTransactions + the "seed demo" button in profile_screen.
  4. shared/formatters/ — empty; add intl money + date formatters; migrate MoneyText + day grouping.
  5. Expand test coverage (currently: transactions repo/controller/form, users controller, onboarding).

Done (was on this list): locale now persisted via settings (AppLocaleController reads settingsStreamProvider, MaterialApp.router reads locale); transfer balance aggregation is implemented in month_summary.dart (the case transfer: break; is the intentional "All accounts" branch — transfers count only when a specific account is selected).

Open decisions (discuss before implementing)

  • Transfer model: single record with transferToAccountId (current) 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