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>
271 lines
18 KiB
Markdown
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
|