Files
OnBudget/CLAUDE.md
T
SandersandClaude Opus 4.8 da65bf6f8e Add per-app rules, transfer pairing, self-merchant flag, two-step onboarding
Notification parsing:
- Per-app parse rules (parse_rules.packageName; getEnabledForApp, NULL = legacy global)
- Transfer pairing: transfer_pair_matcher + transfer_pairing_blocklist table/dao/repo
- source_apps.selfMerchant flag (Ozon inbox rework: default account picker, suppress
  AI category prefill + rule suggestion)
- raw_messages.diagnostics dump captured under diagnostic-mode toggle
- Inbox card / settings / log UI reworks

Onboarding:
- Two-step flow (name -> first account); UserSeeder seeds categories only, no accounts

Schema bumped to v11; drop obsolete migration + mixed-merchant tests, add new coverage.
Add ios/ platform folder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:49:04 +03:00

14 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=11
      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), data/native/ (notification
                             #   listener channel; Kotlin NotificationIngestService falls back to
                             #   tickerText when extras body is empty or a "содержимое скрыто"
                             #   stub — VTB puts the real text ONLY in ticker, no second post),
                             #   NotificationIngestWorker + 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 (+diagnostics — full notification-extras dump, captured only while the diagnostic-mode toggle in parsing settings is on), parse_rules (+txType — transaction type pinned at rule creation, checked by the gate; +packageName — rules are per-app: pipeline loads only rules of the message's source app via getEnabledForApp (NULL = legacy global rows still match anywhere); every create path must pass packageName), rule_candidates (NOT app-scoped — key is userId+kind+rawValue), account_bindings (+isDefault per-app default binding), source_apps (allowlist of monitored apps; +selfMerchant — "merchant is the app itself", e.g. Ozon: suppresses AI category prefill and the rule suggestion in Inbox), 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. onboarding_screen.dart is a two-step flow (name → first account) that does no DB writes until the final submit: it calls usersController.createUser (which runs UserSeeder.seedForNewUser(userId) — now seeds default categories only, no accounts), then creates the user's first account via accountsController.createAccount and marks it default, then setActiveUser (which clears the redirect → /home). Default accounts are no longer auto-seeded. Demo accounts + transactions are added only via the manual "seed demo" button in profile_screen (seedDemoTransactionsForUser self-heals missing accounts/categories; 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 inputs are direct repository stream subscriptions (no intermediate autoDispose stream providers): container.read(parsingWorkerProvider(userId)) alone is enough to drain in tests. Rationale: an internal ref.listen of a paused provider (a worker with no listeners of its own, i.e. bare-container tests) does not activate its autoDispose dependencies — Riverpod 3 pause semantics. Don't reintroduce stream providers as worker inputs.
  • Integration tests hitting the real DeepSeek API live in test/features/notification_parsing/integration/, tagged @Tags(['integration']). Run with flutter test ... --tags integration --dart-define=DEEPSEEK_API_KEY=sk-... (optional --dart-define=DEEPSEEK_TEST_MODEL=..., default kDefaultAiModel = deepseek-chat; 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. seedForNewUser now seeds only categories (accounts are created by the user in onboarding); what remains to remove is the manual demo path — seedDemoTransactionsForUser / _seedDemoTransactions / _seedAccounts + 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