13 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=8
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), 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,
parse_rules (+txType — transaction type pinned at rule creation, checked by the gate),
rule_candidates, account_bindings (+isDefault per-app default binding),
source_apps (allowlist of monitored apps), transfer_pairing_blocklist. Their enums live
in notification_parsing/domain/enums.dart; converters in .../data/drift/converters.dart (both
imported by app_database.dart).
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 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
@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; after
usersController.createUser, the redirect clears and UserSeeder.seedForNewUser(userId)
seeds default accounts and categories. Demo transactions are not auto-seeded — they're
added only via the manual "seed demo" button in profile_screen (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. ParsingWorkerwon't drain oncontainer.readalone. Its inputpendingMessagesProvideris autoDispose and only subscribes to the Drift stream when it has a direct listener (in-app that'sHomeScreen). In a bareProviderContaineraddcontainer.listen(pendingMessagesProvider(userId), (_, _) {}, fireImmediately: true)alongside reading the worker, or pending messages stay stuck atpending.- Integration tests hitting real OpenRouter live in
test/features/notification_parsing/integration/, tagged@Tags(['integration']). Run withflutter test ... --tags integration --dart-define=OPENROUTER_API_KEY=sk-or-...(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. Auto-seeding on user creation is already gone (seedForNewUseronly seeds accounts- categories); what remains is the manual path —
seedDemoTransactionsForUser/_seedDemoTransactions+ the "seed demo" button inprofile_screen.
- categories); what remains is the manual path —
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