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

271 lines
18 KiB
Markdown

# 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<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