Files
SandersandClaude Opus 4.8 eed9164150 Add CompanionDevice watch pairing and store raw AI responses
Two fixes for the notification-parsing pipeline:

- CompanionDeviceManager pairing (DEVICE_PROFILE_WATCH -> COMPANION_DEVICE_WATCH
  role -> RECEIVE_SENSITIVE_NOTIFICATIONS) via a new platform channel in
  MainActivity.kt, companion_device_channel.dart and companion_access_controller,
  surfaced as a card in parsing settings. Lifts the system "Confidential"
  redaction that hid VTB/T-Bank notification text from the listener.

- raw_messages.ai_response: the model's raw completion content is now persisted
  (schema v4 + migration) and shown in the parsing log detail panel. Unlike
  draftJson it survives inbox sweeps and is filled even for partial/ignored
  outcomes.

flutter analyze: no issues.

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

18 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=3 (+onUpgrade v1→v2→v3)
      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.
                             #   data/native/companion_device_channel.dart + канал в
                             #   MainActivity.kt: привязка «часов» через CompanionDeviceManager
                             #   (DEVICE_PROFILE_WATCH → роль COMPANION_DEVICE_WATCH →
                             #   RECEIVE_SENSITIVE_NOTIFICATIONS, снимает заглушку
                             #   «Конфиденциально»); карточка в parsing_settings.
                             #   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_rulesunified rule model, NO kind column (dropped in v2→v3): one rule = condition (pattern + matchMode + optional txTypeGuard type guard, SQL column still tx_type) + any set of actions (merchantCanonical/categoryId/accountId, all nullable) OR isIgnore (mutually exclusive with actions); domain helpers ParseRule.classifies (merchant/category set) and .routesAccount (account set) replace kind checks; +autoApply — per-rule toggle: false → matches land in Inbox fully prefilled (gate check ruleAutoApplyEnabled); +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. ParseRulesRepositoryImpl.create has a dedup guard: same condition (trimmed case-insensitive pattern + matchMode + packageName, NULL package on the existing row matches any app) → updates/reactivates the existing row instead of inserting ("one condition = one rule"; ignore over a category rule turns it into an ignore rule). Rule resolution is field-wise: findClassificationRule (enabled && classifies) fills merchant/category, findAccountRule (enabled && routesAccount) is step #1 of the account resolver (trusted) — one message may take category and account from different rules. rule_candidates (NOT app-scoped — key is userId+kind+rawValue; ParseRuleKind enum survives ONLY here), 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; +defaultAccountId — the app's default account, FK not enforced → tolerate dangling ids), transfer_pairing_blocklist. Their enums live in notification_parsing/domain/enums.dart; converters in .../data/drift/converters.dart (both imported by app_database.dart).

Account resolution (data/parser/account_resolver.dart, pure sync fn resolveAccount): account rule (findAccountRule: pattern in body → account, trusted) → source_apps.defaultAccountId (trusted) → global default account (NOT trusted → Inbox with prefill) → none. The app default auto-learns: first Confirm/CreateRule in Inbox for an app without a default stores the chosen account (InboxController._maybeSetAppDefault, returns true → card shows a SnackBar; learnAppDefault: false skips). The old account_bindings table (card/phone → account) was dropped in the v1→v2 migration: per-app default (or single) binding became defaultAccountId, card/phone bindings became account contains-rules. Per-app settings live on one screen: source_app_detail_screen.dart (/settings/parsing/apps/:pkg — enabled, selfMerchant, default account picker, account-only rules (routesAccount && !classifies) of that app).

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 txTypeGuard (null = skip), account resolved+trusted, amount ≤ 100 000 ₽, rule's own autoApply toggle on — ruleAutoApplyEnabled), 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.

Inbox sweep (ParsingPipeline.reapplyRulesToInbox(userId, packageName)): after createRule/ignoreWithRule the InboxController re-runs the pipeline tail over the app's inbox cards on their cached AI drafts (zero tokens; called AFTER _maybeSetAppDefault so learned defaults make card accounts trusted; failures are swallowed — the action already succeeded). Matching cards auto-apply through the same gate or get updated in place (category prefilled, suggestion gone, failedChecks recorded); transfer halves / merged pairs are skipped. Rule editor (rule_editor_screen.dart) is one unified form: condition → ignore switch → actions (merchant, category, account with "Auto — app account" placeholder) → autoApply switch → advanced (txTypeGuard dropdown, priority, enabled).

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