Files
OnBudget/CLAUDE.md
T
SandersandClaude Opus 4.8 4f99b80169 Replace account_bindings with per-app default account; build analytics charts
Notification parsing:
- Drop the account_bindings table/DAO/repo/entity/controller/screen; account
  resolution now goes senderToAccount rule -> source_apps.defaultAccountId
  (trusted) -> global default (untrusted -> Inbox), via v1->v2 migration.
- Add defaultAccountId to source_apps; per-app settings consolidated into
  source_app_detail_screen (/settings/parsing/apps/:pkg).
- Inbox auto-learns an app default on first Confirm/CreateRule; account picker
  on the card instead of a disabled button; parse_error_labels extracted.

Analytics:
- Replace placeholder screen with fl_chart cards (chart_card, chart_theme,
  month_stepper, month_math domain helper); slim down habit_analysis_screen.

Android: add launcher icon (adaptive foreground + colors.xml) and app_name.

Tests: migration_v2, analytics (screen/month_math), AI retry, inbox
visibility; update resolver/gate/inbox suites for the new resolution path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:09:12 +03:00

242 lines
15 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=2 (+onUpgrade v1→v2)
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`),
`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`):
`senderToAccount` rule (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 `senderToAccount` contains-rules. Per-app settings live on one
screen: `source_app_detail_screen.dart` (`/settings/parsing/apps/:pkg` — enabled, selfMerchant,
default account picker, senderToAccount rules 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 `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