- Drop ParseRuleKind from parse_rules (enum survives only in rule_candidates): a rule is now a condition (pattern + matchMode + optional txTypeGuard) plus any set of actions (merchantCanonical/categoryId/accountId) or isIgnore - Field-wise resolution: findClassificationRule fills merchant/category, findAccountRule feeds the account resolver - one message may use both - Per-rule autoApply toggle + ruleAutoApplyEnabled gate check; off -> Inbox with full prefill - Dedup guard in ParseRulesRepositoryImpl.create: same condition updates or reactivates the existing row instead of inserting a duplicate - ParsingPipeline.reapplyRulesToInbox: sweep app inbox cards on cached AI drafts after createRule/ignoreWithRule (zero tokens) - Schema v2 -> v3 migration (drop kind, add auto_apply) + migration_v3_test - Rework rule_editor_screen into a single unified form; update rules list, rule cards, source app detail, l10n strings, and tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
17 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_runnerwhenever you touch any.dartfile that has@riverpod,@DriftDatabase,@freezed, or@JsonSerializableannotations, or after editinglib/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 —
@riverpodNotifier/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.
# 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 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
@DriftDatabaseor table files, re-runbuild_runnerand bumpschemaVersioninapp_database.dart. - Riverpod
@riverpodproviders generate into the same*.g.dart— don't split provider + its generated file across separatepartdirectives 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 XxxRepositorytracks calls + supports error/Completergates;FakeXxxController extends XxxControlleroverridesbuild(). - 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.dartexportsisNull/isNotNullwhich clash withpackage:matcher. Useimport 'package:drift/drift.dart' hide isNull, isNotNull;. - Controller errors: use
try/catch + rethrow(seeUsersController). Do NOT useAsyncValue.guard(...).value!— in Riverpod 3.xAsyncError.valueisnull, so!throwsTypeErrorinstead 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 afterpumpWidget. ParsingWorkerinputs are direct repository stream subscriptions (no intermediate autoDispose stream providers):container.read(parsingWorkerProvider(userId))alone is enough to drain in tests. Rationale: an internalref.listenof 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 withflutter test ... --tags integration --dart-define=DEEPSEEK_API_KEY=sk-...(optional--dart-define=DEEPSEEK_TEST_MODEL=..., defaultkDefaultAiModel=deepseek-chat; key never hardcoded; testsskip:when it's absent so defaultflutter teststays green/offline). OverrideaiKeyStoreProviderwith a fake key store +isOnlineProviderwithStream.value(true)(connectivity_plus has no binding underflutter test); assert the terminalRawMessageStatusand decodedraftJsonviadecodeDraftBundle. Drafts for merchants WITHOUT a user rule never auto-apply (gate checkruleMatchedfails) — expectinbox; with a rule + amount present in the body + trusted account the gate auto-applies.
What's left (priority order)
- Analytics screen — still a
PlaceholderScreenhub (only the habit-analysis tile);fl_chartis not used anywhere inanalytics/yet. Build charts over transaction streams. - Persist theme via
settingsController:themeModeControlleris still in-memory (build() => ThemeMode.dark) andapp.dartreads it, not settings. Thesettings.themeModecolumn exists but is unused. MirrorAppLocaleController(locale is already persisted). - Fully remove demo-transaction code from
UserSeederonce the add-transaction flow is solid.seedForNewUsernow 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 inprofile_screen. shared/formatters/— empty; add intl money + date formatters; migrateMoneyText+ day grouping.- 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