# NewBudget — Flutter personal finance app ## Commands ```bash 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-l10n` → `lib/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_rules` — **unified 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 `AutoApplyCheck`s (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()!` 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