diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3a08f94..4b17e1f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -21,7 +21,10 @@ "Bash(grep -E \"compileSdkVersion|minSdkVersion|targetSdkVersion\" \"C:\\\\\\\\Sanders\\\\\\\\FlutterSDK\\\\\\\\flutter\\\\\\\\packages\\\\\\\\flutter_tools\\\\\\\\gradle\\\\\\\\flutter.gradle\")", "Bash(grep -r \"compileSdk\\\\|minSdk\\\\|targetSdk\" \"C:\\\\\\\\Sanders\\\\\\\\FlutterSDK\\\\\\\\flutter\\\\\\\\packages\\\\\\\\flutter_tools\\\\\\\\gradle\")", "Bash(grep -E \"compileSdkVersion|minSdkVersion|targetSdkVersion\" \"C:\\\\\\\\Sanders\\\\\\\\FlutterSDK\\\\\\\\flutter\\\\\\\\packages\\\\\\\\flutter_tools\\\\\\\\gradle\\\\\\\\bin\\\\\\\\main\\\\\\\\FlutterExtension.kt\")", - "Bash(xargs wc -l)" + "Bash(xargs wc -l)", + "Bash(git -C . worktree list)", + "Bash(xargs grep -l \"class\")", + "PowerShell(dart run build_runner build --delete-conflicting-outputs)" ] } } diff --git a/.gitignore b/.gitignore index 037ef9c..c5874a3 100644 --- a/.gitignore +++ b/.gitignore @@ -68,10 +68,34 @@ app.*.map.json *.keystore # iOS / macOS +# NOTE: never blanket-ignore *.xcworkspace/ — contents.xcworkspacedata and +# xcshareddata must be committed. Only per-user data (xcuserdata) is ignored. +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/.last_build_id +**/ios/Flutter/App.framework **/ios/Flutter/Flutter.framework **/ios/Flutter/Flutter.podspec -**/ios/**/*.xcworkspace/ -**/macos/**/*.xcworkspace/ +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral/ +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* Pods/ .symlinks/ diff --git a/.metadata b/.metadata index cda28f3..344170c 100644 --- a/.metadata +++ b/.metadata @@ -15,7 +15,7 @@ migration: - platform: root create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - - platform: android + - platform: ios create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 diff --git a/CLAUDE.md b/CLAUDE.md index 77d5f4d..822e544 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,7 @@ lib/ 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/app_database.dart # @DriftDatabase, schemaVersion=11 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) @@ -82,7 +82,11 @@ lib/ 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. + # 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/ @@ -104,10 +108,17 @@ Every domain table has a `userId` FK → `users`. | `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 +**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`), +`account_bindings` (+`isDefault` per-app default binding), +`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), +`transfer_pairing_blocklist`. Their enums live in `notification_parsing/domain/enums.dart`; converters in `.../data/drift/converters.dart` (both imported by `app_database.dart`). @@ -142,11 +153,15 @@ Do not use hard-coded color constants in widgets. 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). +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`, @@ -172,15 +187,17 @@ Icon/color helpers that used to live in `_mock_data.dart` now live with their fe - Access the container inside a widget test via `ProviderScope.containerOf(tester.element(find.byType(MyScreen)))` to manipulate notifier state after `pumpWidget`. -- **`ParsingWorker` won't drain on `container.read` alone.** Its input - `pendingMessagesProvider` is autoDispose and only subscribes to the Drift stream when it - has a *direct* listener (in-app that's `HomeScreen`). In a bare `ProviderContainer` add - `container.listen(pendingMessagesProvider(userId), (_, _) {}, fireImmediately: true)` - alongside reading the worker, or pending messages stay stuck at `pending`. -- **Integration tests hitting real OpenRouter** live in +- **`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=OPENROUTER_API_KEY=sk-or-...` - (key never hardcoded; tests `skip:` when it's absent so default `flutter test` stays + 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 @@ -195,9 +212,9 @@ Icon/color helpers that used to live in `_mock_data.dart` now live with their fe (`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. Auto-seeding on user creation is already gone (`seedForNewUser` only seeds accounts - + categories); what remains is the manual path — `seedDemoTransactionsForUser` / - `_seedDemoTransactions` + the "seed demo" button in `profile_screen`. + 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). diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8a13ba6..6aa918d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ - NewBudget — распознавание уведомлений + Kitty finances — распознавание уведомлений diff --git a/docs/TEST_PLAN.md b/docs/TEST_PLAN.md index 19ab030..2c18b39 100644 --- a/docs/TEST_PLAN.md +++ b/docs/TEST_PLAN.md @@ -277,10 +277,11 @@ await verifier.migrateAndValidate(db, 8); Сейчас сидер всюду фейкается; реальный код не исполняется ни одним тестом, при этом от него зависит первый запуск приложения. -1. `seedForNewUser(userId)` → созданы дефолтные счета и категории (количества > 0; - точные наборы не пиннить — они будут меняться), все с правильным `userId`. -2. Один счёт помечен `isDefault` (если это контракт сидера — проверить по коду). -3. Демо-транзакции созданы и ссылаются на посеянные счета/категории (FK-цепочка цела). +1. `seedForNewUser(userId)` → созданы дефолтные **категории** (количество > 0; точные + наборы не пиннить), все с правильным `userId`. Счета НЕ создаются и `isDefault` не + выставляется — первый счёт создаёт пользователь на втором шаге онбординга. +2. `seedDemoTransactionsForUser(userId)` (ручной путь из профиля) — создаёт недостающие + счета/категории и демо-транзакции, ссылающиеся на них (FK-цепочка цела). Тест оформить так, чтобы при удалении демо-сида (пункт 2 бэклога CLAUDE.md) достаточно было удалить один блок ассертов. 4. Повторный вызов для того же пользователя: пиннить фактическое поведение diff --git a/docs/account_determination_plan.md b/docs/account_determination_plan.md index d596543..d3e4ec6 100644 --- a/docs/account_determination_plan.md +++ b/docs/account_determination_plan.md @@ -147,7 +147,8 @@ unique `(userId, packageName)`. Присутствие строки = прило `accountTrusted=false` (multi-binding) → `inbox`; нет счёта → `inbox`. 4. **Allowlist-тест** (`parsing_worker`): сообщение от пакета НЕ из включённых → `ignored`, AI не вызывается; от включённого банка без карты, но с глобальным дефолтом + правилом категории → авто-применение на дефолт. - (Помнить про `container.listen(pendingMessagesProvider(userId), …)` — см. CLAUDE.md.) + (Устарело: `pendingMessagesProvider` удалён — воркер подписан на Drift-стримы репозитория + напрямую, в тестах достаточно `container.read(parsingWorkerProvider(userId))`.) 5. Существующий пакет `test/features/notification_parsing/` — зелёный. 6. Ручная проверка (`flutter run`): добавить приложение из каталога → включить → задать привязку к счёту и дефолт; убедиться, что уведомление от не-включённого пакета игнорируется; создать `senderToAccount`-правило. diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..6486ffd --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,644 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.sanders.budget.newBudget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sanders.budget.newBudget.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sanders.budget.newBudget.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sanders.budget.newBudget.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.sanders.budget.newBudget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.sanders.budget.newBudget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..ffe1705 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,75 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Kitty finances + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + en + ru + + CFBundleName + new_budget + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d79c55a..85c420a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,7 +1,7 @@ { "@@locale": "en", - "appTitle": "NewBudget", + "appTitle": "Kitty finances", "navHome": "Home", "navAnalytics": "Analytics", @@ -84,6 +84,8 @@ "onboardingNameLabel": "Your name", "onboardingNameHint": "How should we address you?", "onboardingContinue": "Continue", + "onboardingAccountTitle": "Your first account", + "onboardingAccountSubtitle": "Add an account to track your money. You can add more later.", "txNewTitle": "New transaction", "txEditTitle": "Edit transaction", @@ -208,6 +210,8 @@ "parsingEnableLabel": "Recognize notifications", "parsingAutoApplyLabel": "Auto-add transactions", "parsingAutoApplyHint": "Add without confirmation when a rule exists, the amount is found in the message text and the account is trusted.", + "parsingTransferPairingLabel": "Merge transfers between accounts", + "parsingTransferPairingHint": "Two notifications about one transfer (debit + credit) are merged into a single transfer transaction pending confirmation.", "parsingDiagnosticModeLabel": "Diagnostic mode", "parsingDiagnosticModeHint": "Capture every notification from monitored apps (incl. empty/duplicate) with a full dump of its fields. Temporary — for debugging, adds noise to the parsing log.", "inboxWhyNotAuto": "Why not automatic: {reasons}", @@ -247,6 +251,9 @@ "parsingStatusFailed": "Failed", "parsingStatusPendingAi": "Waiting for AI", "parsingStatusParsedPartial": "Partial", + "parsingStatusWaitingPair": "Waiting for pair", + "parsingStatusPaired": "Merged into transfer", + "parsingUnmergeTransfer": "Split transfer", "parsingLogOnline": "Online", "parsingLogOffline": "Offline", @@ -294,12 +301,15 @@ "@inboxCreateRule": { "placeholders": { "merchant": { "type": "String" }, "category": { "type": "String" } } }, "inboxCreateRuleNoCategory": "Create rule for “{merchant}”", "@inboxCreateRuleNoCategory": { "placeholders": { "merchant": { "type": "String" } } }, - "inboxConfirmOnce": "Confirm once", "inboxConfirm": "Confirm", - "inboxMarkMixed": "Categories vary", + "inboxCategoryRequiredHint": "Pick a category to confirm", "inboxIgnore": "Ignore", "inboxApplyAll": "Apply all", - "inboxHideAll": "Hide all", + "inboxTransferPairTitle": "Transfer between accounts", + "inboxTransferFrom": "From account", + "inboxTransferTo": "To account", + "inboxTransferPickAccount": "Choose…", + "inboxUnpair": "Split", "inboxUnrecognized": "Not recognized", "inboxParseErrorTitle": "Recognition error", "inboxRetry": "Try again", @@ -326,13 +336,16 @@ "ruleEditorModeContains": "Contains", "ruleEditorModeExact": "Exact", "ruleEditorModeRegex": "Regex", - "ruleKindMixedHint": "No category rule is suggested for this merchant: the category is picked manually for each transaction.", "ruleEditorThen": "Then it is:", "ruleEditorMerchant": "Merchant", "ruleEditorMerchantHint": "e.g. Wildberries", "ruleEditorCategory": "Category", "ruleEditorAccount": "Account", "ruleEditorAccountUnchanged": "— keep unchanged —", + "ruleEditorApp": "App", + "ruleEditorAppPick": "Select app", + "ruleEditorNoApps": "Add a source app first", + "rulesUnknownApp": "Unknown app", "ruleEditorAdvanced": "Advanced", "ruleEditorPriority": "Priority", "ruleEditorMatchesTitle": "Matches (last 30 days)", @@ -416,6 +429,8 @@ "sourceAppsSearchHint": "Search", "sourceAppsPickerEmpty": "No apps found", "sourceAppsAlreadyAdded": "Already added", + "sourceAppsSelfMerchantLabel": "Merchant is the app itself", + "sourceAppsSelfMerchantHint": "Notifications never name the seller: pick the category manually, no rules are suggested", "appBindingsTitle": "Account bindings", "appBindingsEmpty": "No bindings yet. Add one to map a card to an account.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3c44cb3..6f41f88 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -101,7 +101,7 @@ abstract class AppLocalizations { /// No description provided for @appTitle. /// /// In ru, this message translates to: - /// **'NewBudget'** + /// **'Kitty finances'** String get appTitle; /// No description provided for @navHome. @@ -308,6 +308,18 @@ abstract class AppLocalizations { /// **'Продолжить'** String get onboardingContinue; + /// No description provided for @onboardingAccountTitle. + /// + /// In ru, this message translates to: + /// **'Ваш первый счёт'** + String get onboardingAccountTitle; + + /// No description provided for @onboardingAccountSubtitle. + /// + /// In ru, this message translates to: + /// **'Добавьте счёт, чтобы учитывать деньги. Другие счета можно добавить позже.'** + String get onboardingAccountSubtitle; + /// No description provided for @txNewTitle. /// /// In ru, this message translates to: @@ -920,6 +932,18 @@ abstract class AppLocalizations { /// **'Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.'** String get parsingAutoApplyHint; + /// No description provided for @parsingTransferPairingLabel. + /// + /// In ru, this message translates to: + /// **'Склеивать переводы между счетами'** + String get parsingTransferPairingLabel; + + /// No description provided for @parsingTransferPairingHint. + /// + /// In ru, this message translates to: + /// **'Два уведомления об одном переводе (списание + зачисление) объединяются в одну транзакцию-перевод на подтверждение.'** + String get parsingTransferPairingHint; + /// No description provided for @parsingDiagnosticModeLabel. /// /// In ru, this message translates to: @@ -1136,6 +1160,24 @@ abstract class AppLocalizations { /// **'Частично'** String get parsingStatusParsedPartial; + /// No description provided for @parsingStatusWaitingPair. + /// + /// In ru, this message translates to: + /// **'Ждёт пару'** + String get parsingStatusWaitingPair; + + /// No description provided for @parsingStatusPaired. + /// + /// In ru, this message translates to: + /// **'Объединено в перевод'** + String get parsingStatusPaired; + + /// No description provided for @parsingUnmergeTransfer. + /// + /// In ru, this message translates to: + /// **'Расклеить перевод'** + String get parsingUnmergeTransfer; + /// No description provided for @parsingLogOnline. /// /// In ru, this message translates to: @@ -1382,23 +1424,17 @@ abstract class AppLocalizations { /// **'Создать правило для «{merchant}»'** String inboxCreateRuleNoCategory(String merchant); - /// No description provided for @inboxConfirmOnce. - /// - /// In ru, this message translates to: - /// **'Подтвердить разово'** - String get inboxConfirmOnce; - /// No description provided for @inboxConfirm. /// /// In ru, this message translates to: /// **'Подтвердить'** String get inboxConfirm; - /// No description provided for @inboxMarkMixed. + /// No description provided for @inboxCategoryRequiredHint. /// /// In ru, this message translates to: - /// **'Категории различаются'** - String get inboxMarkMixed; + /// **'Выберите категорию, чтобы подтвердить'** + String get inboxCategoryRequiredHint; /// No description provided for @inboxIgnore. /// @@ -1412,11 +1448,35 @@ abstract class AppLocalizations { /// **'Учесть все'** String get inboxApplyAll; - /// No description provided for @inboxHideAll. + /// No description provided for @inboxTransferPairTitle. /// /// In ru, this message translates to: - /// **'Скрыть разово'** - String get inboxHideAll; + /// **'Перевод между счетами'** + String get inboxTransferPairTitle; + + /// No description provided for @inboxTransferFrom. + /// + /// In ru, this message translates to: + /// **'Счёт списания'** + String get inboxTransferFrom; + + /// No description provided for @inboxTransferTo. + /// + /// In ru, this message translates to: + /// **'Счёт зачисления'** + String get inboxTransferTo; + + /// No description provided for @inboxTransferPickAccount. + /// + /// In ru, this message translates to: + /// **'Выбрать…'** + String get inboxTransferPickAccount; + + /// No description provided for @inboxUnpair. + /// + /// In ru, this message translates to: + /// **'Расклеить'** + String get inboxUnpair; /// No description provided for @inboxUnrecognized. /// @@ -1556,12 +1616,6 @@ abstract class AppLocalizations { /// **'Regex'** String get ruleEditorModeRegex; - /// No description provided for @ruleKindMixedHint. - /// - /// In ru, this message translates to: - /// **'Для этого мерчанта правила-категории не предлагаются: категория выбирается вручную для каждой операции.'** - String get ruleKindMixedHint; - /// No description provided for @ruleEditorThen. /// /// In ru, this message translates to: @@ -1598,6 +1652,30 @@ abstract class AppLocalizations { /// **'— не менять —'** String get ruleEditorAccountUnchanged; + /// No description provided for @ruleEditorApp. + /// + /// In ru, this message translates to: + /// **'Приложение'** + String get ruleEditorApp; + + /// No description provided for @ruleEditorAppPick. + /// + /// In ru, this message translates to: + /// **'Выберите приложение'** + String get ruleEditorAppPick; + + /// No description provided for @ruleEditorNoApps. + /// + /// In ru, this message translates to: + /// **'Сначала добавьте приложение-источник'** + String get ruleEditorNoApps; + + /// No description provided for @rulesUnknownApp. + /// + /// In ru, this message translates to: + /// **'Неизвестное приложение'** + String get rulesUnknownApp; + /// No description provided for @ruleEditorAdvanced. /// /// In ru, this message translates to: @@ -1946,6 +2024,18 @@ abstract class AppLocalizations { /// **'Уже добавлено'** String get sourceAppsAlreadyAdded; + /// No description provided for @sourceAppsSelfMerchantLabel. + /// + /// In ru, this message translates to: + /// **'Мерчант — само приложение'** + String get sourceAppsSelfMerchantLabel; + + /// No description provided for @sourceAppsSelfMerchantHint. + /// + /// In ru, this message translates to: + /// **'Уведомления не называют продавца: категория выбирается вручную, правила не предлагаются'** + String get sourceAppsSelfMerchantHint; + /// No description provided for @appBindingsTitle. /// /// In ru, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 1940335..34d7194 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -9,7 +9,7 @@ class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); @override - String get appTitle => 'NewBudget'; + String get appTitle => 'Kitty finances'; @override String get navHome => 'Home'; @@ -142,6 +142,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get onboardingContinue => 'Continue'; + @override + String get onboardingAccountTitle => 'Your first account'; + + @override + String get onboardingAccountSubtitle => + 'Add an account to track your money. You can add more later.'; + @override String get txNewTitle => 'New transaction'; @@ -475,6 +482,13 @@ class AppLocalizationsEn extends AppLocalizations { String get parsingAutoApplyHint => 'Add without confirmation when a rule exists, the amount is found in the message text and the account is trusted.'; + @override + String get parsingTransferPairingLabel => 'Merge transfers between accounts'; + + @override + String get parsingTransferPairingHint => + 'Two notifications about one transfer (debit + credit) are merged into a single transfer transaction pending confirmation.'; + @override String get parsingDiagnosticModeLabel => 'Diagnostic mode'; @@ -593,6 +607,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get parsingStatusParsedPartial => 'Partial'; + @override + String get parsingStatusWaitingPair => 'Waiting for pair'; + + @override + String get parsingStatusPaired => 'Merged into transfer'; + + @override + String get parsingUnmergeTransfer => 'Split transfer'; + @override String get parsingLogOnline => 'Online'; @@ -724,14 +747,11 @@ class AppLocalizationsEn extends AppLocalizations { return 'Create rule for “$merchant”'; } - @override - String get inboxConfirmOnce => 'Confirm once'; - @override String get inboxConfirm => 'Confirm'; @override - String get inboxMarkMixed => 'Categories vary'; + String get inboxCategoryRequiredHint => 'Pick a category to confirm'; @override String get inboxIgnore => 'Ignore'; @@ -740,7 +760,19 @@ class AppLocalizationsEn extends AppLocalizations { String get inboxApplyAll => 'Apply all'; @override - String get inboxHideAll => 'Hide all'; + String get inboxTransferPairTitle => 'Transfer between accounts'; + + @override + String get inboxTransferFrom => 'From account'; + + @override + String get inboxTransferTo => 'To account'; + + @override + String get inboxTransferPickAccount => 'Choose…'; + + @override + String get inboxUnpair => 'Split'; @override String get inboxUnrecognized => 'Not recognized'; @@ -820,10 +852,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get ruleEditorModeRegex => 'Regex'; - @override - String get ruleKindMixedHint => - 'No category rule is suggested for this merchant: the category is picked manually for each transaction.'; - @override String get ruleEditorThen => 'Then it is:'; @@ -842,6 +870,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get ruleEditorAccountUnchanged => '— keep unchanged —'; + @override + String get ruleEditorApp => 'App'; + + @override + String get ruleEditorAppPick => 'Select app'; + + @override + String get ruleEditorNoApps => 'Add a source app first'; + + @override + String get rulesUnknownApp => 'Unknown app'; + @override String get ruleEditorAdvanced => 'Advanced'; @@ -1022,6 +1062,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get sourceAppsAlreadyAdded => 'Already added'; + @override + String get sourceAppsSelfMerchantLabel => 'Merchant is the app itself'; + + @override + String get sourceAppsSelfMerchantHint => + 'Notifications never name the seller: pick the category manually, no rules are suggested'; + @override String get appBindingsTitle => 'Account bindings'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index a4762b7..90ca3aa 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -9,7 +9,7 @@ class AppLocalizationsRu extends AppLocalizations { AppLocalizationsRu([String locale = 'ru']) : super(locale); @override - String get appTitle => 'NewBudget'; + String get appTitle => 'Kitty finances'; @override String get navHome => 'Главная'; @@ -148,6 +148,13 @@ class AppLocalizationsRu extends AppLocalizations { @override String get onboardingContinue => 'Продолжить'; + @override + String get onboardingAccountTitle => 'Ваш первый счёт'; + + @override + String get onboardingAccountSubtitle => + 'Добавьте счёт, чтобы учитывать деньги. Другие счета можно добавить позже.'; + @override String get txNewTitle => 'Новая операция'; @@ -487,6 +494,13 @@ class AppLocalizationsRu extends AppLocalizations { String get parsingAutoApplyHint => 'Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.'; + @override + String get parsingTransferPairingLabel => 'Склеивать переводы между счетами'; + + @override + String get parsingTransferPairingHint => + 'Два уведомления об одном переводе (списание + зачисление) объединяются в одну транзакцию-перевод на подтверждение.'; + @override String get parsingDiagnosticModeLabel => 'Режим диагностики'; @@ -604,6 +618,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String get parsingStatusParsedPartial => 'Частично'; + @override + String get parsingStatusWaitingPair => 'Ждёт пару'; + + @override + String get parsingStatusPaired => 'Объединено в перевод'; + + @override + String get parsingUnmergeTransfer => 'Расклеить перевод'; + @override String get parsingLogOnline => 'Онлайн'; @@ -734,14 +757,12 @@ class AppLocalizationsRu extends AppLocalizations { return 'Создать правило для «$merchant»'; } - @override - String get inboxConfirmOnce => 'Подтвердить разово'; - @override String get inboxConfirm => 'Подтвердить'; @override - String get inboxMarkMixed => 'Категории различаются'; + String get inboxCategoryRequiredHint => + 'Выберите категорию, чтобы подтвердить'; @override String get inboxIgnore => 'Игнорировать'; @@ -750,7 +771,19 @@ class AppLocalizationsRu extends AppLocalizations { String get inboxApplyAll => 'Учесть все'; @override - String get inboxHideAll => 'Скрыть разово'; + String get inboxTransferPairTitle => 'Перевод между счетами'; + + @override + String get inboxTransferFrom => 'Счёт списания'; + + @override + String get inboxTransferTo => 'Счёт зачисления'; + + @override + String get inboxTransferPickAccount => 'Выбрать…'; + + @override + String get inboxUnpair => 'Расклеить'; @override String get inboxUnrecognized => 'Не распознано'; @@ -832,10 +865,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get ruleEditorModeRegex => 'Regex'; - @override - String get ruleKindMixedHint => - 'Для этого мерчанта правила-категории не предлагаются: категория выбирается вручную для каждой операции.'; - @override String get ruleEditorThen => 'То это:'; @@ -854,6 +883,18 @@ class AppLocalizationsRu extends AppLocalizations { @override String get ruleEditorAccountUnchanged => '— не менять —'; + @override + String get ruleEditorApp => 'Приложение'; + + @override + String get ruleEditorAppPick => 'Выберите приложение'; + + @override + String get ruleEditorNoApps => 'Сначала добавьте приложение-источник'; + + @override + String get rulesUnknownApp => 'Неизвестное приложение'; + @override String get ruleEditorAdvanced => 'Дополнительно'; @@ -1034,6 +1075,13 @@ class AppLocalizationsRu extends AppLocalizations { @override String get sourceAppsAlreadyAdded => 'Уже добавлено'; + @override + String get sourceAppsSelfMerchantLabel => 'Мерчант — само приложение'; + + @override + String get sourceAppsSelfMerchantHint => + 'Уведомления не называют продавца: категория выбирается вручную, правила не предлагаются'; + @override String get appBindingsTitle => 'Привязки счетов'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index a4768c4..fce0d84 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1,7 +1,7 @@ { "@@locale": "ru", - "appTitle": "NewBudget", + "appTitle": "Kitty finances", "navHome": "Главная", "navAnalytics": "Аналитика", @@ -84,6 +84,8 @@ "onboardingNameLabel": "Ваше имя", "onboardingNameHint": "Например, Алекс", "onboardingContinue": "Продолжить", + "onboardingAccountTitle": "Ваш первый счёт", + "onboardingAccountSubtitle": "Добавьте счёт, чтобы учитывать деньги. Другие счета можно добавить позже.", "txNewTitle": "Новая операция", "txEditTitle": "Редактировать операцию", @@ -208,6 +210,8 @@ "parsingEnableLabel": "Распознавать уведомления", "parsingAutoApplyLabel": "Авто-добавление транзакций", "parsingAutoApplyHint": "Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.", + "parsingTransferPairingLabel": "Склеивать переводы между счетами", + "parsingTransferPairingHint": "Два уведомления об одном переводе (списание + зачисление) объединяются в одну транзакцию-перевод на подтверждение.", "parsingDiagnosticModeLabel": "Режим диагностики", "parsingDiagnosticModeHint": "Ловить каждое уведомление от мониторимых приложений (включая пустые/повторные) с полным дампом всех полей. Временно — для отладки, добавляет шум в журнал парсинга.", "inboxWhyNotAuto": "Почему не автоматически: {reasons}", @@ -247,6 +251,9 @@ "parsingStatusFailed": "Ошибка", "parsingStatusPendingAi": "Ждёт AI", "parsingStatusParsedPartial": "Частично", + "parsingStatusWaitingPair": "Ждёт пару", + "parsingStatusPaired": "Объединено в перевод", + "parsingUnmergeTransfer": "Расклеить перевод", "parsingLogOnline": "Онлайн", "parsingLogOffline": "Офлайн", @@ -294,12 +301,15 @@ "@inboxCreateRule": { "placeholders": { "merchant": { "type": "String" }, "category": { "type": "String" } } }, "inboxCreateRuleNoCategory": "Создать правило для «{merchant}»", "@inboxCreateRuleNoCategory": { "placeholders": { "merchant": { "type": "String" } } }, - "inboxConfirmOnce": "Подтвердить разово", "inboxConfirm": "Подтвердить", - "inboxMarkMixed": "Категории различаются", + "inboxCategoryRequiredHint": "Выберите категорию, чтобы подтвердить", "inboxIgnore": "Игнорировать", "inboxApplyAll": "Учесть все", - "inboxHideAll": "Скрыть разово", + "inboxTransferPairTitle": "Перевод между счетами", + "inboxTransferFrom": "Счёт списания", + "inboxTransferTo": "Счёт зачисления", + "inboxTransferPickAccount": "Выбрать…", + "inboxUnpair": "Расклеить", "inboxUnrecognized": "Не распознано", "inboxParseErrorTitle": "Ошибка распознавания", "inboxRetry": "Попробовать снова", @@ -326,13 +336,16 @@ "ruleEditorModeContains": "Содержит", "ruleEditorModeExact": "Точно", "ruleEditorModeRegex": "Regex", - "ruleKindMixedHint": "Для этого мерчанта правила-категории не предлагаются: категория выбирается вручную для каждой операции.", "ruleEditorThen": "То это:", "ruleEditorMerchant": "Мерчант", "ruleEditorMerchantHint": "Напр. Wildberries", "ruleEditorCategory": "Категория", "ruleEditorAccount": "Счёт", "ruleEditorAccountUnchanged": "— не менять —", + "ruleEditorApp": "Приложение", + "ruleEditorAppPick": "Выберите приложение", + "ruleEditorNoApps": "Сначала добавьте приложение-источник", + "rulesUnknownApp": "Неизвестное приложение", "ruleEditorAdvanced": "Дополнительно", "ruleEditorPriority": "Приоритет", "ruleEditorMatchesTitle": "Совпадает с (последние 30 дней)", @@ -416,6 +429,8 @@ "sourceAppsSearchHint": "Поиск", "sourceAppsPickerEmpty": "Приложения не найдены", "sourceAppsAlreadyAdded": "Уже добавлено", + "sourceAppsSelfMerchantLabel": "Мерчант — само приложение", + "sourceAppsSelfMerchantHint": "Уведомления не называют продавца: категория выбирается вручную, правила не предлагаются", "appBindingsTitle": "Привязки счетов", "appBindingsEmpty": "Пока нет привязок. Добавьте, чтобы связать карту со счётом.", diff --git a/lib/src/app/app.dart b/lib/src/app/app.dart index a36c315..b9648ab 100644 --- a/lib/src/app/app.dart +++ b/lib/src/app/app.dart @@ -17,7 +17,7 @@ class NewBudgetApp extends ConsumerWidget { final locale = ref.watch(appLocaleControllerProvider); return MaterialApp.router( - title: 'NewBudget', + title: 'Kitty finances', debugShowCheckedModeBanner: false, theme: AppTheme.light(), darkTheme: AppTheme.dark(), diff --git a/lib/src/app/router/app_router.dart b/lib/src/app/router/app_router.dart index 0065d80..fbf2f41 100644 --- a/lib/src/app/router/app_router.dart +++ b/lib/src/app/router/app_router.dart @@ -59,9 +59,11 @@ GoRouter appRouter(Ref ref) { ), GoRoute( path: AppRoutes.transactionNew, - pageBuilder: (context, state) => _slideUpPage( + pageBuilder: (context, state) => _slideUpPage( state, - const TransactionFormScreen(), + TransactionFormScreen( + prefill: state.extra as TransactionFormPrefill?, + ), ), ), GoRoute( diff --git a/lib/src/core/database/app_database.dart b/lib/src/core/database/app_database.dart index 9ad66a6..30db003 100644 --- a/lib/src/core/database/app_database.dart +++ b/lib/src/core/database/app_database.dart @@ -28,6 +28,7 @@ import '../../features/notification_parsing/data/drift/daos/parse_rules_dao.dart import '../../features/notification_parsing/data/drift/daos/rule_candidates_dao.dart'; import '../../features/notification_parsing/data/drift/daos/account_bindings_dao.dart'; import '../../features/notification_parsing/data/drift/daos/source_apps_dao.dart'; +import '../../features/notification_parsing/data/drift/daos/transfer_pairing_blocklist_dao.dart'; part 'app_database.g.dart'; @@ -59,6 +60,7 @@ part 'app_database.g.dart'; RuleCandidatesDao, AccountBindingsDao, SourceAppsDao, + TransferPairingBlocklistDao, ], ) class AppDatabase extends _$AppDatabase { @@ -68,74 +70,13 @@ class AppDatabase extends _$AppDatabase { AppDatabase.forTesting(super.executor); @override - int get schemaVersion => 9; + int get schemaVersion => 1; @override MigrationStrategy get migration => MigrationStrategy( onCreate: (m) async { await m.createAll(); }, - onUpgrade: (m, from, to) async { - if (from < 2) { - // v1 → v2: PK сменились с int autoIncrement на UUID text. - await m.drop(transactionsTable); - await m.drop(categoriesTable); - await m.drop(accountsTable); - await m.drop(settingsTable); - await m.drop(usersTable); - await m.createAll(); - } - if (from < 3) { - // v2 → v3: добавлено поле extra_info в transactions. - await m.addColumn(transactionsTable, transactionsTable.extraInfo); - } - if (from < 4) { - // v3 → v4: добавлено поле is_default в accounts. - await m.addColumn(accountsTable, accountsTable.isDefault); - } - if (from < 5) { - // v4 → v5: notification-parsing schema. - // - // Transactions: note → merchant + новые поля парсинга. - await m.renameColumn( - transactionsTable, 'note', transactionsTable.merchant); - await m.addColumn( - transactionsTable, transactionsTable.rawMessageId); - await m.addColumn( - transactionsTable, transactionsTable.autoApplied); - await m.addColumn( - transactionsTable, transactionsTable.appliedByRuleId); - // Новые таблицы: - await m.createTable(rawMessagesTable); - await m.createTable(parseRulesTable); - await m.createTable(ruleCandidatesTable); - await m.createTable(accountBindingsTable); - await m.createTable(transferPairingBlocklistTable); - } - if (from < 6) { - // v5 → v6: habit-tracking поля. - await m.addColumn( - transactionsTable, transactionsTable.obligation); - await m.addColumn(transactionsTable, transactionsTable.impulse); - await m.addColumn( - settingsTable, settingsTable.habitTrackingEnabled); - } - if (from < 7) { - // v6 → v7: умолчательные привязки + allowlist приложений-источников. - await m.addColumn( - accountBindingsTable, accountBindingsTable.isDefault); - await m.createTable(sourceAppsTable); - } - if (from < 8) { - // v7 → v8: тип операции фиксируется в правиле (для gate-проверки - // typeMatchesRule). - await m.addColumn(parseRulesTable, parseRulesTable.txType); - } - if (from < 9) { - // v8 → v9: дамп нативного уведомления для режима диагностики. - await m.addColumn(rawMessagesTable, rawMessagesTable.diagnostics); - } - }, ); static QueryExecutor _openConnection() { diff --git a/lib/src/features/analytics/application/habit_analysis_providers.dart b/lib/src/features/analytics/application/habit_analysis_providers.dart index b126983..d9870a8 100644 --- a/lib/src/features/analytics/application/habit_analysis_providers.dart +++ b/lib/src/features/analytics/application/habit_analysis_providers.dart @@ -11,28 +11,31 @@ part 'habit_analysis_providers.g.dart'; // Состояние фильтров (autoDispose) // --------------------------------------------------------------------------- -/// Фильтр по импульсивности: `null` = «Все». +/// Фильтр по импульсивности (главная шкала каскада): `null` = «Все». @riverpod class HabitImpulseFilter extends _$HabitImpulseFilter { @override SpendingImpulse? build() => null; - void select(SpendingImpulse? value) => state = value; + void select(SpendingImpulse? value) { + // У «Обязательно» импульсивности не бывает (инвариант формы транзакции) — + // активный импульс-фильтр несовместим с этим значением обязательности. + if (value != null && + ref.read(habitObligationFilterProvider) == + SpendingObligation.required) { + ref.read(habitObligationFilterProvider.notifier).select(null); + } + state = value; + } } -/// Фильтр по обязательности (мульти-выбор). Пустое множество = «Все». +/// Фильтр по обязательности (вторичная шкала): `null` = «Все». @riverpod class HabitObligationFilter extends _$HabitObligationFilter { @override - Set build() => const {}; + SpendingObligation? build() => null; - void toggle(SpendingObligation value) { - final next = Set.from(state); - if (!next.add(value)) next.remove(value); - state = next; - } - - void clear() => state = const {}; + void select(SpendingObligation? value) => state = value; } // --------------------------------------------------------------------------- @@ -71,12 +74,16 @@ Map habitSumByImpulse(Ref ref, String userId) { return map; } -/// Суммы расходов по обязательности (включая ключ `null` = «без оценки»). +/// Суммы расходов по обязательности (включая ключ `null` = «без оценки») +/// внутри выборки активного импульс-фильтра: вторая строка каскада +/// показывает разбивку выбранного импульс-сегмента. @riverpod Map habitSumByObligation(Ref ref, String userId) { final txs = ref.watch(habitMonthTransactionsProvider(userId)); + final impulse = ref.watch(habitImpulseFilterProvider); final map = {}; for (final t in txs) { + if (impulse != null && t.impulse != impulse) continue; map[t.obligation] = (map[t.obligation] ?? 0) + t.amount; } return map; @@ -87,14 +94,11 @@ Map habitSumByObligation(Ref ref, String userId) { List habitFilteredTransactions(Ref ref, String userId) { final txs = ref.watch(habitMonthTransactionsProvider(userId)); final impulse = ref.watch(habitImpulseFilterProvider); - final obligations = ref.watch(habitObligationFilterProvider); + final obligation = ref.watch(habitObligationFilterProvider); return txs.where((t) { if (impulse != null && t.impulse != impulse) return false; - if (obligations.isNotEmpty && - (t.obligation == null || !obligations.contains(t.obligation))) { - return false; - } + if (obligation != null && t.obligation != obligation) return false; return true; }).toList(); } diff --git a/lib/src/features/analytics/presentation/screens/habit_analysis_screen.dart b/lib/src/features/analytics/presentation/screens/habit_analysis_screen.dart index 2c9de4c..b6555bd 100644 --- a/lib/src/features/analytics/presentation/screens/habit_analysis_screen.dart +++ b/lib/src/features/analytics/presentation/screens/habit_analysis_screen.dart @@ -110,10 +110,15 @@ class _HeaderBar extends ConsumerWidget { final monthTitle = '$monthCap ${month.year}'; return Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), + padding: const EdgeInsets.fromLTRB(4, 8, 16, 8), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: Icon(Icons.close, color: p.ink), + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + ), Expanded( child: Text( l10n.habitAnalysisTitle, @@ -197,6 +202,14 @@ class _FilterBlock extends ConsumerWidget { final totalImpulse = impulseSums.values.fold(0, (s, v) => s + v); + final totalObligation = + obligationSums.values.fold(0, (s, v) => s + v); + // «Обязательно» несовместимо с импульс-фильтром (инвариант формы). + final obligationValues = impulseFilter == null + ? SpendingObligation.values + : SpendingObligation.values + .where((o) => o != SpendingObligation.required) + .toList(); return Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), @@ -242,24 +255,38 @@ class _FilterBlock extends ConsumerWidget { const SizedBox(height: 16), _ScaleCaption(l10n.habitScaleObligationCaps), const SizedBox(height: 6), - Row( - children: [ - for (final o in SpendingObligation.values) ...[ + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: Row( + children: [ Expanded( child: _ObligationChip( - title: _obligationFilterLabel(l10n, o), - sumMinor: obligationSums[o] ?? 0, - dotColor: _dotColor(hc, o), - selected: obligationFilter.contains(o), + title: l10n.habitFilterAll, + sumMinor: totalObligation, + selected: obligationFilter == null, onTap: () => ref .read(habitObligationFilterProvider.notifier) - .toggle(o), + .select(null), ), ), - if (o != SpendingObligation.values.last) + for (final o in obligationValues) ...[ const SizedBox(width: 8), + Expanded( + child: _ObligationChip( + title: _obligationFilterLabel(l10n, o), + sumMinor: obligationSums[o] ?? 0, + dotColor: _dotColor(hc, o), + selected: obligationFilter == o, + onTap: () => ref + .read(habitObligationFilterProvider.notifier) + .select(obligationFilter == o ? null : o), + ), + ), + ], ], - ], + ), ), const SizedBox(height: 4), Divider(height: 1, color: p.line), @@ -360,14 +387,14 @@ class _ObligationChip extends StatelessWidget { const _ObligationChip({ required this.title, required this.sumMinor, - required this.dotColor, required this.selected, required this.onTap, + this.dotColor, }); final String title; final int sumMinor; - final Color dotColor; + final Color? dotColor; final bool selected; final VoidCallback onTap; @@ -392,13 +419,15 @@ class _ObligationChip extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Container( - width: 8, - height: 8, - decoration: - BoxDecoration(color: dotColor, shape: BoxShape.circle), - ), - const SizedBox(width: 6), + if (dotColor != null) ...[ + Container( + width: 8, + height: 8, + decoration: + BoxDecoration(color: dotColor, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + ], Flexible( child: Text( title, diff --git a/lib/src/features/notification_parsing/application/inbox_controller.dart b/lib/src/features/notification_parsing/application/inbox_controller.dart index 2adb25f..1bf681e 100644 --- a/lib/src/features/notification_parsing/application/inbox_controller.dart +++ b/lib/src/features/notification_parsing/application/inbox_controller.dart @@ -1,9 +1,11 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../../../core/database/converters/enum_converters.dart'; import '../../accounts/application/account_providers.dart'; +import '../../transactions/application/transaction_providers.dart'; import '../../transactions/application/transactions_controller.dart'; import '../data/parser/draft_codec.dart'; -import '../data/parser/rule_lookup.dart'; +import '../data/parser/transfer_pair_matcher.dart'; import '../domain/entities/parse_draft.dart'; import '../domain/entities/raw_message.dart'; import '../domain/enums.dart'; @@ -26,6 +28,35 @@ Stream inboxCount(Ref ref, String userId) => Stream> parsingLog(Ref ref, String userId) => ref.watch(rawMessagesRepositoryProvider).watchAll(userId); +/// Частые категории подтверждённых транзакций этого приложения — чипы +/// быстрого выбора на карточке (источники с selfMerchant: Ozon и т.п.). +@riverpod +Stream> topConfirmCategories( + Ref ref, + String userId, + String packageName, + TransactionType type, +) => + ref + .watch(rawMessagesRepositoryProvider) + .watchTopCategoryIds(userId, packageName, type); + +/// Вторая половинка склеенной пары — merged-карточка показывает её +/// приложение-источник. +@riverpod +Future pairedRawMessage(Ref ref, String id) => + ref.watch(rawMessagesRepositoryProvider).findById(id); + +/// Отображаемое имя приложения-источника (fallback — packageName). +@riverpod +Future sourceAppLabel( + Ref ref, String userId, String packageName) async { + final app = await ref + .watch(sourceAppsRepositoryProvider) + .findByPackageName(userId, packageName); + return app?.displayName ?? packageName; +} + /// Действия над карточками Inbox (§9.1). Все мутации — явные тапы пользователя. @Riverpod(keepAlive: true) class InboxController extends _$InboxController { @@ -61,6 +92,7 @@ class InboxController extends _$InboxController { await ref.read(parseRulesRepositoryProvider).create( userId: userId, + packageName: message.packageName, kind: ParseRuleKind.merchantToCategory, matchMode: matchMode, pattern: pattern, @@ -125,60 +157,12 @@ class InboxController extends _$InboxController { } } - /// «Категории различаются»: помечает мерчанта как mixed (§9.1) — у него - /// категория варьируется (Ozon, маркетплейсы), поэтому merchant→category - /// правило для него бессмысленно. Создаёт правило-маркер `mixedMerchant`, - /// удаляет уже созданное merchant→category правило и наблюдённого кандидата, - /// затем перезаписывает кеш `draftJson` без suggestion — карточка тут же - /// переключается на «подтвердить разово». - Future markMerchantMixed({ - required String userId, - required RawMessage message, - required DraftBundle bundle, - required String merchantCanonical, - }) async { - state = const AsyncLoading(); - try { - final draft = bundle.draft; - - await ref.read(parseRulesRepositoryProvider).create( - userId: userId, - kind: ParseRuleKind.mixedMerchant, - matchMode: MatchMode.contains, - pattern: merchantCanonical, - ); - - // Снести уже существующее merchant→category правило для этого мерчанта, - // чтобы оно перестало авто-применять одну категорию. - final rules = await ref.read(parseRulesRepositoryProvider).getByUser(userId); - final existing = findMerchantRule(rules, - body: message.body, merchantRaw: draft.merchantRaw); - if (existing != null) { - await ref.read(parseRulesRepositoryProvider).deleteById(existing.id); - } - if (draft.merchantRaw != null) { - await ref - .read(ruleCandidatesRepositoryProvider) - .deleteByRawValue(userId, draft.merchantRaw!); - } - - await ref.read(rawMessagesRepositoryProvider).updateAfterParse( - id: message.id, - status: RawMessageStatus.inbox, - draftJson: - encodeDraftBundle(draft, null, failedChecks: bundle.failedChecks), - confidenceAmount: message.confidenceAmount, - confidenceAccount: message.confidenceAccount, - confidenceType: message.confidenceType, - confidenceMerchant: message.confidenceMerchant, - confidenceCategory: message.confidenceCategory, - ); - state = const AsyncData(null); - } catch (e, st) { - state = AsyncError(e, st); - rethrow; - } - } + /// Привязка сообщения к транзакции, сохранённой через полную форму + /// (карандаш на карточке): status → applied, сообщение уходит из Inbox. + Future markApplied(RawMessage message, String transactionId) => + ref + .read(rawMessagesRepositoryProvider) + .linkTransaction(message.id, transactionId); /// «Попробовать снова»: сбросить сообщение в очередь на повторный разбор /// (status → pending, попытки → 0). Воркер переобработает его заново. @@ -186,11 +170,157 @@ class InboxController extends _$InboxController { await ref.read(rawMessagesRepositoryProvider).resetForRetry(message.id); } - /// «Игнорировать»: статус ignored, без правила. + /// «Игнорировать»: статус ignored, без правила. У склеенной карточки + /// перевода гасит обе половинки. Future ignore(RawMessage message) async { - await ref - .read(rawMessagesRepositoryProvider) - .updateStatus(message.id, RawMessageStatus.ignored); + final repo = ref.read(rawMessagesRepositoryProvider); + await repo.updateStatus(message.id, RawMessageStatus.ignored); + final pairedId = decodeDraftBundle(message.draftJson)?.pairedRawMessageId; + if (pairedId != null) { + await repo.updateStatus(pairedId, RawMessageStatus.ignored); + } + } + + /// «Подтвердить» склеенную карточку перевода: одна транзакция + /// type=transfer, обе половинки → applied с общим transactionId. + Future confirmPair({ + required String userId, + required RawMessage message, + required ParseDraft draft, + required String secondaryId, + required String fromAccountId, + required String toAccountId, + }) async { + state = const AsyncLoading(); + try { + final tx = await ref + .read(transactionsControllerProvider.notifier) + .createTransaction( + userId: userId, + accountId: fromAccountId, + categoryId: null, + type: TransactionType.transfer, + amount: draft.amount, + date: draft.dateTime ?? message.receivedAt, + merchant: draft.merchantCanonical ?? draft.merchantRaw, + transferToAccountId: toAccountId, + rawMessageId: message.id, + ); + final repo = ref.read(rawMessagesRepositoryProvider); + await repo.linkTransaction(message.id, tx.id); + await repo.linkTransaction(secondaryId, tx.id); + state = const AsyncData(null); + } catch (e, st) { + state = AsyncError(e, st); + rethrow; + } + } + + /// «Расклеить» склеенную карточку из Inbox: сигнатура пары в blocklist + /// (sweep её больше не склеит), primary восстанавливается из `preMerge`, + /// secondary возвращается в Inbox со своим одиночным draftJson. + Future unpair({ + required String userId, + required RawMessage message, + }) async { + final bundle = decodeDraftBundle(message.draftJson); + final secondaryId = bundle?.pairedRawMessageId; + if (bundle == null || secondaryId == null) return; + state = const AsyncLoading(); + try { + await ref + .read(transferPairingBlocklistRepositoryProvider) + .add(userId, pairSignature(message.id, secondaryId)); + + final pre = bundle.preMerge; + final preType = pre?['type']; + final restored = bundle.draft.copyWith( + type: preType is String + ? TransactionType.values.byName(preType) + : bundle.draft.type, + categoryId: pre?['categoryId'] as String?, + transferToAccountId: null, + ); + final repo = ref.read(rawMessagesRepositoryProvider); + await repo.updateAfterParse( + id: message.id, + status: RawMessageStatus.inbox, + draftJson: encodeDraftBundle(restored, bundle.suggestion, + accountTrusted: bundle.accountTrusted), + confidenceAmount: message.confidenceAmount, + confidenceAccount: message.confidenceAccount, + confidenceType: message.confidenceType, + confidenceMerchant: message.confidenceMerchant, + confidenceCategory: message.confidenceCategory, + ); + await repo.clearPairing(message.id); + await repo.clearPairing(secondaryId); + await repo.updateStatus(secondaryId, RawMessageStatus.inbox); + state = const AsyncData(null); + } catch (e, st) { + state = AsyncError(e, st); + rethrow; + } + } + + /// «Расклеить перевод» после доклейки (из журнала): откатывает транзакцию + /// по `mergeUndo`, отвязывает половинку и возвращает её в Inbox одиночкой. + Future unmergeApplied({ + required String userId, + required RawMessage message, + }) async { + final bundle = decodeDraftBundle(message.draftJson); + final undo = bundle?.mergeUndo; + if (bundle == null || undo == null) return; + state = const AsyncLoading(); + try { + final txId = undo['txId'] as String?; + if (txId != null) { + final tx = + await ref.read(transactionRepositoryProvider).findById(txId); + if (tx != null) { + final prevType = undo['prevType']; + await ref + .read(transactionsControllerProvider.notifier) + .updateTransaction(tx.copyWith( + type: prevType is String + ? TransactionType.values.byName(prevType) + : tx.type, + accountId: (undo['prevAccountId'] as String?) ?? tx.accountId, + categoryId: undo['prevCategoryId'] as String?, + transferToAccountId: + undo['prevTransferToAccountId'] as String?, + )); + } + } + + final partnerId = bundle.pairedRawMessageId; + if (partnerId != null) { + await ref + .read(transferPairingBlocklistRepositoryProvider) + .add(userId, pairSignature(message.id, partnerId)); + } + + final repo = ref.read(rawMessagesRepositoryProvider); + await repo.unlinkTransaction(message.id); + await repo.updateAfterParse( + id: message.id, + status: RawMessageStatus.inbox, + draftJson: encodeDraftBundle(bundle.draft, bundle.suggestion, + accountTrusted: bundle.accountTrusted), + confidenceAmount: message.confidenceAmount, + confidenceAccount: message.confidenceAccount, + confidenceType: message.confidenceType, + confidenceMerchant: message.confidenceMerchant, + confidenceCategory: message.confidenceCategory, + ); + await repo.clearPairing(message.id); + if (partnerId != null) await repo.clearPairing(partnerId); + state = const AsyncData(null); + } catch (e, st) { + state = AsyncError(e, st); + rethrow; + } } /// «Игнорировать» + правило-исключение (kind=ignore). @@ -204,6 +334,7 @@ class InboxController extends _$InboxController { try { await ref.read(parseRulesRepositoryProvider).create( userId: userId, + packageName: message.packageName, kind: ParseRuleKind.ignore, matchMode: matchMode, pattern: pattern, @@ -218,7 +349,9 @@ class InboxController extends _$InboxController { } } - /// «Учесть все транзакции»: массовое «подтвердить разово». + /// «Учесть все транзакции»: массовое «подтвердить разово». Склеенные + /// карточки переводов подтверждаются через [confirmPair] и пропускаются, + /// пока не выбраны оба счёта. Future applyAll(String userId) async { final messages = await ref.read(rawMessagesRepositoryProvider).watchInbox(userId).first; @@ -227,6 +360,23 @@ class InboxController extends _$InboxController { for (final msg in messages) { final bundle = decodeDraftBundle(msg.draftJson); if (bundle == null) continue; + + final secondaryId = bundle.pairedRawMessageId; + if (secondaryId != null) { + final from = bundle.draft.accountId; + final to = bundle.draft.transferToAccountId; + if (from == null || to == null) continue; + await confirmPair( + userId: userId, + message: msg, + draft: bundle.draft, + secondaryId: secondaryId, + fromAccountId: from, + toAccountId: to, + ); + continue; + } + final accountId = bundle.draft.accountId ?? defaultAccount?.id; if (accountId == null) continue; await confirmOnce( @@ -239,16 +389,6 @@ class InboxController extends _$InboxController { } } - /// «Скрыть разово»: убрать из Inbox без создания транзакций. - Future hideAll(String userId) async { - final messages = - await ref.read(rawMessagesRepositoryProvider).watchInbox(userId).first; - final repo = ref.read(rawMessagesRepositoryProvider); - for (final msg in messages) { - await repo.updateStatus(msg.id, RawMessageStatus.ignored); - } - } - /// Создаёт привязку «карта → счёт», если её ещё нет — чтобы следующее /// сообщение того же мерчанта прошло gate молча (account score 100). Future _maybeBind( diff --git a/lib/src/features/notification_parsing/application/notification_parsing_providers.dart b/lib/src/features/notification_parsing/application/notification_parsing_providers.dart index efb4e2f..980f3c8 100644 --- a/lib/src/features/notification_parsing/application/notification_parsing_providers.dart +++ b/lib/src/features/notification_parsing/application/notification_parsing_providers.dart @@ -7,11 +7,13 @@ import '../data/repositories/parse_rules_repository_impl.dart'; import '../data/repositories/raw_messages_repository_impl.dart'; import '../data/repositories/rule_candidates_repository_impl.dart'; import '../data/repositories/source_apps_repository_impl.dart'; +import '../data/repositories/transfer_pairing_blocklist_repository_impl.dart'; import '../domain/repositories/account_bindings_repository.dart'; import '../domain/repositories/parse_rules_repository.dart'; import '../domain/repositories/raw_messages_repository.dart'; import '../domain/repositories/rule_candidates_repository.dart'; import '../domain/repositories/source_apps_repository.dart'; +import '../domain/repositories/transfer_pairing_blocklist_repository.dart'; part 'notification_parsing_providers.g.dart'; @@ -39,6 +41,12 @@ AccountBindingsRepository accountBindingsRepository(Ref ref) => SourceAppsRepository sourceAppsRepository(Ref ref) => SourceAppsRepositoryImpl(ref.watch(appDatabaseProvider).sourceAppsDao); +@Riverpod(keepAlive: true) +TransferPairingBlocklistRepository transferPairingBlocklistRepository( + Ref ref) => + TransferPairingBlocklistRepositoryImpl( + ref.watch(appDatabaseProvider).transferPairingBlocklistDao); + /// Множество включённых packageName пользователя. Drift-стрим: эмитит каждое /// изменение allowlist, чтобы native-синк в [NotificationIngestWorker] не /// протухал до перезапуска. Подписка на стрим требует прямого слушателя diff --git a/lib/src/features/notification_parsing/application/parsing_pipeline.dart b/lib/src/features/notification_parsing/application/parsing_pipeline.dart index 1d63a6b..7f5b95b 100644 --- a/lib/src/features/notification_parsing/application/parsing_pipeline.dart +++ b/lib/src/features/notification_parsing/application/parsing_pipeline.dart @@ -1,8 +1,10 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../../../core/database/converters/enum_converters.dart'; import '../../accounts/application/account_providers.dart'; import '../../categories/application/category_providers.dart'; import '../../categories/domain/entities/category.dart'; +import '../../transactions/application/transaction_providers.dart'; import '../../transactions/application/transactions_controller.dart'; import '../data/deepseek/deepseek_client.dart'; import '../data/parser/account_resolver.dart'; @@ -12,6 +14,7 @@ import '../data/parser/decision_gate.dart'; import '../data/parser/draft_codec.dart'; import '../data/parser/rule_lookup.dart'; import '../data/parser/rule_suggester.dart'; +import '../data/parser/transfer_pair_matcher.dart'; import '../domain/entities/parse_draft.dart'; import '../domain/entities/raw_message.dart'; import '../domain/entities/rule_candidate.dart'; @@ -48,14 +51,12 @@ class ParsingPipeline { if (!settings.enabled) return; // фича выключена — оставляем pending. // Allowlist (§A): парсим только включённые приложения-источники. Делаем - // ДО AI, чтобы не тратить токены на посторонние пакеты. Снапшот берём из - // репозитория напрямую (`.first` Drift-стрима): свежий на каждый вызов; - // stream-провайдер без прямого слушателя не подписался бы на стрим. - final enabled = await _ref + // ДО AI, чтобы не тратить токены на посторонние пакеты. Точечный lookup + // вместо снапшота стрима: заодно нужен флаг selfMerchant источника. + final sourceApp = await _ref .read(sourceAppsRepositoryProvider) - .watchEnabledPackages(userId) - .first; - if (!enabled.contains(msg.packageName)) { + .findByPackageName(userId, msg.packageName); + if (sourceApp == null || !sourceApp.enabled) { await _ref .read(rawMessagesRepositoryProvider) .updateStatus(msg.id, RawMessageStatus.ignored); @@ -66,8 +67,9 @@ class ParsingPipeline { // на пуши, которые пользователь явно просил пропускать («Доставлен заказ»). // `exact`-правила матчат имя мерчанта, доступное только после AI, — их // проверяет findIgnoreRule в _runPipeline. - final bodyRules = - await _ref.read(parseRulesRepositoryProvider).getEnabledByUser(userId); + final bodyRules = await _ref + .read(parseRulesRepositoryProvider) + .getEnabledForApp(userId, msg.packageName); if (findBodyIgnoreRule(bodyRules, msg.body) != null) { await _ref .read(rawMessagesRepositoryProvider) @@ -76,7 +78,8 @@ class ParsingPipeline { } // Извлечение через AI (regex-этап удалён). Без AI/сети — Inbox/ignored. - await _extractViaAi(userId, msg, settings); + await _extractViaAi(userId, msg, settings, + selfMerchant: sourceApp.selfMerchant); } /// Извлечение через AI: спам без цифр → ignored; зовём AI (если разрешён), @@ -84,8 +87,9 @@ class ParsingPipeline { Future _extractViaAi( String userId, RawMessage msg, - ParsingSettings settings, - ) async { + ParsingSettings settings, { + required bool selfMerchant, + }) async { final repo = _ref.read(rawMessagesRepositoryProvider); final settingsCtrl = _ref.read(parsingSettingsControllerProvider.notifier); @@ -161,7 +165,7 @@ class ParsingPipeline { ); case AiParseStatus.draft: await _runPipeline(userId, msg, outcome.draft!, settings, - categories: categories); + categories: categories, selfMerchant: selfMerchant); } } on DeepSeekNetworkException { await repo.updateAfterParse( @@ -189,18 +193,30 @@ class ParsingPipeline { } /// Общий хвост pipeline (§5, шаги 3–8) для AI-draft. + /// + /// [selfMerchant] — источник помечен «мерчант — само приложение» (Ozon): + /// AI-подсказка категории глушится, merchant→category правило не + /// предлагается — категорию пользователь выбирает вручную на карточке. Future _runPipeline( String userId, RawMessage msg, ParseDraft draft0, ParsingSettings settings, { List? categories, + bool selfMerchant = false, }) async { final repo = _ref.read(rawMessagesRepositoryProvider); + if (selfMerchant) { + // «Нет AI-префилла»: карточка читает подсказку из draftJson, поэтому + // достаточно снять её здесь — виджету флаг знать не нужно. + draft0 = draft0.copyWith(categorySuggestion: null); + } + // 4. Правила грузим раньше — нужны резолверу (senderToAccount). - final rules = - await _ref.read(parseRulesRepositoryProvider).getEnabledByUser(userId); + final rules = await _ref + .read(parseRulesRepositoryProvider) + .getEnabledForApp(userId, msg.packageName); if (findIgnoreRule(rules, body: msg.body, merchantRaw: draft0.merchantRaw) != null) { await repo.updateStatus(msg.id, RawMessageStatus.ignored); @@ -249,16 +265,12 @@ class ParsingPipeline { ); } - // Предложение правила для Inbox (только для незнакомого мерчанта, если - // пользователь не пометил его как «категории различаются»). Без мерчанта - // или для mixed-мерчанта suggestion остаётся null → карточка покажет - // «подтвердить разово» вместо кнопки «Создать правило». + // Предложение правила для Inbox — только для незнакомого мерчанта и не + // для selfMerchant-источников (мерчант там — само приложение, правило + // бессмысленно). Без suggestion карточка покажет «Подтвердить» вместо + // кнопки «Создать правило». RuleSuggestion? suggestion; - if (rule == null && - draft.merchantRaw != null && - findMixedMerchantRule(rules, - body: msg.body, merchantRaw: draft.merchantRaw) == - null) { + if (!selfMerchant && rule == null && draft.merchantRaw != null) { // AI-подсказка категории: матчим имя на существующую категорию (§7). String? aiCategoryId; String? aiCategoryName; @@ -300,6 +312,25 @@ class ParsingPipeline { categoryCandidate: candidate, ); + // 7. Transfer pairing: transfer-половинка не идёт через gate — паркуется + // в waitingPair; склейку/доклейку/релиз по таймауту делает [sweepPairs] + // (сериализован воркером). accountTrusted снапшотим в bundle: на момент + // sweep resolution уже недоступен, а доклейка требует trusted-счёт. + if (settings.transferPairingEnabled && isTransferHalf(draft)) { + await repo.holdForPairing( + id: msg.id, + draftJson: encodeDraftBundle(draft, suggestion, + accountTrusted: resolution.trusted), + deadline: DateTime.now().add(kPairingWindow), + confidenceAmount: scores.amount, + confidenceAccount: scores.account, + confidenceType: scores.type, + confidenceMerchant: scores.merchant, + confidenceCategory: scores.category, + ); + return; + } + // 8. Gate: чек-лист именованных проверок (decision_gate.dart). final gate = decide( autoApplyEnabled: settings.autoApplyEnabled, @@ -379,4 +410,237 @@ class ParsingPipeline { .incrementMatchCount(bindingId); } } + + // ── Transfer pairing ─────────────────────────────────────────────────────── + + /// Один проход склейки переводов. НЕ вызывать напрямую параллельно: + /// сериализацию обеспечивает ParsingWorker — все решения о склейке + /// принимает одна последовательная точка, гонок нет по построению. + /// + /// Шаги: (1) пары среди waitingPair; (2) для оставшихся — контрпартнёр в + /// Inbox (склейка) или среди applied (доклейка в существующую транзакцию); + /// (3) релиз просроченных в Inbox одиночными карточками. + Future sweepPairs(String userId) async { + final repo = _ref.read(rawMessagesRepositoryProvider); + final waiting = await repo.watchWaitingPair(userId).first; + if (waiting.isEmpty) return; + + // Декодим bundle каждой ждущей половинки; повреждённые (без draft/kind) + // остаются в списке waiting и уходят в Inbox по дедлайну на шаге 3. + final halves = <_PairHalf>[]; + for (final m in waiting) { + final b = decodeDraftBundle(m.draftJson); + if (b != null && isTransferHalf(b.draft)) halves.add((msg: m, bundle: b)); + } + + final handled = {}; + + // 1. Пары среди самих waitingPair (сюда же попадает бэклог после + // рестарта и обе половинки, распарсенные параллельно). + for (var i = 0; i < halves.length; i++) { + final a = halves[i]; + if (handled.contains(a.msg.id)) continue; + final candidates = <(_PairHalf, DateTime)>[]; + for (var j = i + 1; j < halves.length; j++) { + final b = halves[j]; + if (handled.contains(b.msg.id)) continue; + if (!_isCandidatePair(a, b)) continue; + if (await _isBlocked(userId, a.msg.id, b.msg.id)) continue; + candidates.add((b, b.msg.receivedAt)); + } + if (candidates.isEmpty) continue; + final partner = pickNearest(candidates, a.msg.receivedAt); + await _mergePair(a, partner); + handled..add(a.msg.id)..add(partner.msg.id); + } + + // 2. Контрпартнёр среди Inbox (склейка) / applied (доклейка). + for (final h in halves) { + if (handled.contains(h.msg.id)) continue; + final recent = await repo.findRecentByStatuses( + userId, + h.msg.receivedAt.subtract(kPairingWindow), + h.msg.receivedAt.add(kPairingWindow), + const [RawMessageStatus.inbox, RawMessageStatus.applied], + ); + + final inboxCands = <(_PairHalf, DateTime)>[]; + final appliedCands = <(_PairHalf, DateTime)>[]; + for (final m in recent) { + if (m.id == h.msg.id || m.pairedWithId != null) continue; + final b = decodeDraftBundle(m.draftJson); + if (b == null || !isTransferHalf(b.draft)) continue; + // Уже склеенная merged-карточка второй раз не участвует. + if (b.pairedRawMessageId != null) continue; + final cand = (msg: m, bundle: b); + if (!_isCandidatePair(h, cand)) continue; + if (await _isBlocked(userId, h.msg.id, m.id)) continue; + if (m.status == RawMessageStatus.inbox) { + inboxCands.add((cand, m.receivedAt)); + } else if (m.transactionId != null) { + appliedCands.add((cand, m.receivedAt)); + } + } + + if (inboxCands.isNotEmpty) { + // Контрпартнёр уже в Inbox (релизнут по таймауту раньше / парсился + // при выключенном пейринге) — обычная склейка, его одиночная + // карточка заменяется merged-карточкой. + await _mergePair(h, pickNearest(inboxCands, h.msg.receivedAt)); + handled.add(h.msg.id); + continue; + } + + if (appliedCands.isNotEmpty) { + // Доклейка — только при разрешённом и trusted счёте новой половинки; + // иначе обычный одиночный путь в Inbox без ожидания дедлайна + // (пара уже найдена, но склеивать её автоматически нельзя). + if (h.bundle.draft.accountId != null && h.bundle.accountTrusted) { + final ok = + await _lateMerge(h, pickNearest(appliedCands, h.msg.receivedAt)); + if (ok) { + handled.add(h.msg.id); + continue; + } + } + await repo.updateStatus(h.msg.id, RawMessageStatus.inbox); + handled.add(h.msg.id); + } + } + + // 3. Релиз просроченных в Inbox. Только updateStatus: bundle и + // confidence уже сохранены при holdForPairing, затирать их нельзя. + final now = DateTime.now(); + for (final m in waiting) { + if (handled.contains(m.id)) continue; + final deadline = m.pairDeadline; + if (deadline == null || !deadline.isAfter(now)) { + await repo.updateStatus(m.id, RawMessageStatus.inbox); + } + } + } + + /// Немедленный релиз всех ждущих половинок в Inbox — при выключении + /// тумблера склейки (не держим сообщения скрытыми до дедлайна). + Future releaseAllWaiting(String userId) async { + final repo = _ref.read(rawMessagesRepositoryProvider); + final waiting = await repo.watchWaitingPair(userId).first; + for (final m in waiting) { + await repo.updateStatus(m.id, RawMessageStatus.inbox); + } + } + + bool _isCandidatePair(_PairHalf a, _PairHalf b) => isPair( + a: a.bundle.draft, + aReceivedAt: a.msg.receivedAt, + b: b.bundle.draft, + bReceivedAt: b.msg.receivedAt, + ); + + Future _isBlocked(String userId, String idA, String idB) => + _ref + .read(transferPairingBlocklistRepositoryProvider) + .contains(userId, pairSignature(idA, idB)); + + /// Склейка двух непримененных половинок: primary = transferOut (его счёт — + /// источник) уходит в Inbox merged-карточкой, secondary скрывается как + /// `paired`. Свой draftJson secondary сохраняет — он нужен для расклейки. + Future _mergePair(_PairHalf a, _PairHalf b) async { + final out = a.bundle.draft.kind == TxKind.transferOut ? a : b; + final inn = out == a ? b : a; + + final mergedDraft = out.bundle.draft.copyWith( + type: TransactionType.transfer, + transferToAccountId: inn.bundle.draft.accountId, + categoryId: null, + ); + + final repo = _ref.read(rawMessagesRepositoryProvider); + await repo.updateAfterParse( + id: out.msg.id, + status: RawMessageStatus.inbox, + draftJson: encodeDraftBundle( + mergedDraft, + // Suggestion сохраняем: merged-карточка его не показывает, но после + // расклейки primary снова становится обычной карточкой. + out.bundle.suggestion, + accountTrusted: out.bundle.accountTrusted, + pairedRawMessageId: inn.msg.id, + preMerge: { + 'type': out.bundle.draft.type.name, + 'categoryId': out.bundle.draft.categoryId, + }, + ), + confidenceAmount: out.msg.confidenceAmount, + confidenceAccount: out.msg.confidenceAccount, + confidenceType: out.msg.confidenceType, + confidenceMerchant: out.msg.confidenceMerchant, + confidenceCategory: out.msg.confidenceCategory, + ); + await repo.setPairing(out.msg.id, inn.msg.id); + await repo.setPairing(inn.msg.id, out.msg.id, + status: RawMessageStatus.paired); + } + + /// Доклейка: контрпартнёр уже применён обычной транзакцией — конвертируем + /// её в transfer, новая половинка привязывается к той же транзакции. + /// Снапшот прежних полей tx уходит в `mergeUndo` для отката из журнала. + Future _lateMerge(_PairHalf h, _PairHalf applied) async { + final txId = applied.msg.transactionId; + if (txId == null) return false; + final tx = await _ref.read(transactionRepositoryProvider).findById(txId); + if (tx == null) return false; + + final newAccountId = h.bundle.draft.accountId!; + final updated = applied.bundle.draft.kind == TxKind.transferOut + // Существующая tx — списание: её счёт остаётся источником. + ? tx.copyWith( + type: TransactionType.transfer, + transferToAccountId: newAccountId, + categoryId: null, + ) + // Существующая tx — зачисление: её счёт становится получателем, + // источником — счёт новой out-половинки. + : tx.copyWith( + type: TransactionType.transfer, + accountId: newAccountId, + transferToAccountId: tx.accountId, + categoryId: null, + ); + + await _ref + .read(transactionsControllerProvider.notifier) + .updateTransaction(updated); + + final repo = _ref.read(rawMessagesRepositoryProvider); + await repo.updateAfterParse( + id: h.msg.id, + status: RawMessageStatus.applied, + draftJson: encodeDraftBundle( + h.bundle.draft, + h.bundle.suggestion, + accountTrusted: h.bundle.accountTrusted, + pairedRawMessageId: applied.msg.id, + mergeUndo: { + 'txId': tx.id, + 'prevType': tx.type.name, + 'prevAccountId': tx.accountId, + 'prevCategoryId': tx.categoryId, + 'prevTransferToAccountId': tx.transferToAccountId, + }, + ), + confidenceAmount: h.msg.confidenceAmount, + confidenceAccount: h.msg.confidenceAccount, + confidenceType: h.msg.confidenceType, + confidenceMerchant: h.msg.confidenceMerchant, + confidenceCategory: h.msg.confidenceCategory, + ); + await repo.linkTransaction(h.msg.id, tx.id); + await repo.setPairing(h.msg.id, applied.msg.id); + await repo.setPairing(applied.msg.id, h.msg.id); + return true; + } } + +/// Половинка пары: сообщение + декодированный bundle. +typedef _PairHalf = ({RawMessage msg, DraftBundle bundle}); diff --git a/lib/src/features/notification_parsing/application/parsing_settings_controller.dart b/lib/src/features/notification_parsing/application/parsing_settings_controller.dart index 543c06d..e3af515 100644 --- a/lib/src/features/notification_parsing/application/parsing_settings_controller.dart +++ b/lib/src/features/notification_parsing/application/parsing_settings_controller.dart @@ -11,6 +11,7 @@ class ParsingSettings { const ParsingSettings({ required this.enabled, required this.autoApplyEnabled, + required this.transferPairingEnabled, required this.aiConsentGiven, required this.aiModel, required this.aiDailyTokenLimit, @@ -25,6 +26,11 @@ class ParsingSettings { /// gate-проверок (см. `AutoApplyCheck` в decision_gate.dart). final bool autoApplyEnabled; + /// Склейка переводов между счетами: transfer-половинки ждут пару + /// (`waitingPair`) и объединяются в одну transfer-транзакцию. При + /// выключении идут обычным одиночным путём. + final bool transferPairingEnabled; + /// Дано ли согласие на отправку текста уведомлений в AI (§7/§12.6). /// Без него AI не вызывается — работает только regex. final bool aiConsentGiven; @@ -50,6 +56,7 @@ class ParsingSettings { ParsingSettings copyWith({ bool? enabled, bool? autoApplyEnabled, + bool? transferPairingEnabled, bool? aiConsentGiven, String? aiModel, int? aiDailyTokenLimit, @@ -60,6 +67,8 @@ class ParsingSettings { ParsingSettings( enabled: enabled ?? this.enabled, autoApplyEnabled: autoApplyEnabled ?? this.autoApplyEnabled, + transferPairingEnabled: + transferPairingEnabled ?? this.transferPairingEnabled, aiConsentGiven: aiConsentGiven ?? this.aiConsentGiven, aiModel: aiModel ?? this.aiModel, aiDailyTokenLimit: @@ -74,6 +83,7 @@ const _kEnabled = 'parsing_enabled'; // Старый ключ 'parsing_auto_apply_strictness' (75/85/95) больше не читается: // числовой гейт заменён чек-листом, фича до замены не срабатывала ни разу. const _kAutoApply = 'parsing_auto_apply_enabled'; +const _kTransferPairing = 'parsing_transfer_pairing_enabled'; const _kAiConsent = 'ai_consent'; const _kAiModel = 'ai_model'; const _kAiDailyLimit = 'ai_daily_token_limit'; @@ -84,6 +94,7 @@ const kDefaultAiModel = 'deepseek-chat'; const _defaultSettings = ParsingSettings( enabled: true, autoApplyEnabled: true, + transferPairingEnabled: true, aiConsentGiven: false, aiModel: kDefaultAiModel, aiDailyTokenLimit: null, @@ -107,6 +118,7 @@ class ParsingSettingsController extends _$ParsingSettingsController { final dao = ref.watch(appDatabaseProvider).settingsDao; final enabledStr = await dao.getPreference(_kEnabled); final autoApplyStr = await dao.getPreference(_kAutoApply); + final pairingStr = await dao.getPreference(_kTransferPairing); final consentStr = await dao.getPreference(_kAiConsent); final modelStr = await dao.getPreference(_kAiModel); final limitStr = await dao.getPreference(_kAiDailyLimit); @@ -119,6 +131,9 @@ class ParsingSettingsController extends _$ParsingSettingsController { autoApplyEnabled: autoApplyStr == null ? _defaultSettings.autoApplyEnabled : autoApplyStr == 'true', + transferPairingEnabled: pairingStr == null + ? _defaultSettings.transferPairingEnabled + : pairingStr == 'true', aiConsentGiven: consentStr == 'true', aiModel: (modelStr != null && modelStr.isNotEmpty) ? modelStr @@ -144,6 +159,12 @@ class ParsingSettingsController extends _$ParsingSettingsController { state = AsyncData(current.copyWith(autoApplyEnabled: value)); } + Future setTransferPairingEnabled(bool value) async { + await _set(_kTransferPairing, '$value'); + final current = state.value ?? _defaultSettings; + state = AsyncData(current.copyWith(transferPairingEnabled: value)); + } + Future setDiagnosticMode(bool value) async { await _set(_kDiagnosticMode, '$value'); final current = state.value ?? _defaultSettings; diff --git a/lib/src/features/notification_parsing/application/parsing_worker.dart b/lib/src/features/notification_parsing/application/parsing_worker.dart index fd88e7f..bd7d3dd 100644 --- a/lib/src/features/notification_parsing/application/parsing_worker.dart +++ b/lib/src/features/notification_parsing/application/parsing_worker.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../domain/entities/raw_message.dart'; @@ -9,11 +11,6 @@ import 'parsing_settings_controller.dart'; part 'parsing_worker.g.dart'; -/// Поток необработанных сообщений — вход воркера. -@riverpod -Stream> pendingMessages(Ref ref, String userId) => - ref.watch(rawMessagesRepositoryProvider).watchPending(userId); - /// ParsingWorker (§5): foreground-драйвер pipeline. Слушает /// `raw_messages.pending` и прогоняет каждое сообщение через [ParsingPipeline]. /// Отвечает только за «когда запускать» (триггер/дедуп/ретраи); сама обработка @@ -21,7 +18,14 @@ Stream> pendingMessages(Ref ref, String userId) => /// поверх того же кода без дублирования. /// /// Идемпотентен: при возврате сообщения в `pending` перепарсивается. -/// Провайдер `keepAlive` — активируется чтением из HomeScreen. +/// Провайдер `keepAlive` — активируется `ref.watch` из AppScaffold. +/// +/// Оба входа (pending и waitingPair) — прямые подписки на Drift-стримы +/// репозитория, БЕЗ промежуточных autoDispose stream-провайдеров: у тех нет +/// UI-слушателей, а внутренний `ref.listen` приостановленного воркера (bare +/// `ProviderContainer` в тестах) их не активирует — pause-семантика riverpod 3. +/// Прямая подписка не зависит от activity-семантики вовсе; в тестах достаточно +/// `container.read(parsingWorkerProvider(userId))`. @Riverpod(keepAlive: true) class ParsingWorker extends _$ParsingWorker { final Set _inFlight = {}; @@ -30,25 +34,31 @@ class ParsingWorker extends _$ParsingWorker { // (иначе при перезапуске приложения они не ретраятся — §7). bool _wasOnline = false; + // Сериализация sweep склейки переводов: одновременно работает максимум + // один sweepPairs; запрос, пришедший во время работы, выполняется следом. + bool _sweeping = false; + bool _sweepRequested = false; + Timer? _pairTimer; + @override void build(String userId) { - final sub = ref.listen( - pendingMessagesProvider(userId), - (_, next) { - final list = next.value; - if (list == null || list.isEmpty) return; - // Фича выключена: pipeline всё равно no-op'нул бы каждое сообщение — - // не прогоняем растущий pending заново на каждую новую вставку. - // Обратное включение тумблера запускает явный drain (см. enabledSub). - if (ref.read(parsingSettingsControllerProvider).value?.enabled == - false) { - return; - } - _drain(userId, list); - }, - fireImmediately: true, - ); - ref.onDispose(sub.close); + // Первая эмиссия Drift-стрима — текущий снапшот: бэклог `pending`, + // накопившийся до старта приложения, дренится сразу. + final pendingSub = ref + .read(rawMessagesRepositoryProvider) + .watchPending(userId) + .listen((list) { + if (list.isEmpty) return; + // Фича выключена: pipeline всё равно no-op'нул бы каждое сообщение — + // не прогоняем растущий pending заново на каждую новую вставку. + // Обратное включение тумблера запускает явный drain (см. enabledSub). + if (ref.read(parsingSettingsControllerProvider).value?.enabled == + false) { + return; + } + _drain(userId, list); + }); + ref.onDispose(pendingSub.cancel); // Размораживаем очередь при обратном включении фичи: raw_messages не // менялись, поэтому pending-стрим сам не переэмитит застрявшие сообщения. @@ -75,6 +85,72 @@ class ParsingWorker extends _$ParsingWorker { }, ); ref.onDispose(onlineSub.close); + + // Transfer pairing: каждая эмиссия waitingPair (новая половинка, склейка, + // релиз) запускает sweep и перевзводит таймер ближайшего дедлайна. + // Первая эмиссия покрывает старт приложения: дедлайны в БД, рестарт + // переживаем. НЕ гейтится флагом enabled — релиз в Inbox не требует AI. + final waitingSub = ref + .read(rawMessagesRepositoryProvider) + .watchWaitingPair(userId) + .listen((list) { + _armPairTimer(userId, list); + if (list.isNotEmpty) _requestSweep(userId); + }); + ref.onDispose(waitingSub.cancel); + ref.onDispose(() => _pairTimer?.cancel()); + + // Выключение тумблера склейки — немедленный релиз ждущих половинок в + // Inbox (не держим сообщения скрытыми до дедлайна). + final pairingSub = ref.listen( + parsingSettingsControllerProvider, + (prev, next) { + if (next.value?.transferPairingEnabled == false && + prev?.value?.transferPairingEnabled == true) { + ref.read(parsingPipelineProvider).releaseAllWaiting(userId); + } + }, + ); + ref.onDispose(pairingSub.close); + } + + /// Перевзводит таймер на ближайший `pairDeadline` (sweep по его истечении + /// релизнет просроченные половинки в Inbox). + void _armPairTimer(String userId, List waiting) { + _pairTimer?.cancel(); + _pairTimer = null; + DateTime? nearest; + for (final m in waiting) { + final d = m.pairDeadline; + if (d != null && (nearest == null || d.isBefore(nearest))) nearest = d; + } + if (nearest == null) return; + var delay = nearest.difference(DateTime.now()); + if (delay.isNegative) delay = Duration.zero; + // +1s, чтобы при срабатывании дедлайн гарантированно был в прошлом. + _pairTimer = Timer(delay + const Duration(seconds: 1), () { + _requestSweep(userId); + }); + } + + /// Сериализованный запуск [ParsingPipeline.sweepPairs]: параллельные + /// запросы схлопываются в «выполнить ещё раз после текущего прохода». + /// Sweep идемпотентен (без работы — без записей), поэтому цикл + /// «запись → эмиссия стрима → повторный запрос» затухает сам. + Future _requestSweep(String userId) async { + if (_sweeping) { + _sweepRequested = true; + return; + } + _sweeping = true; + try { + do { + _sweepRequested = false; + await ref.read(parsingPipelineProvider).sweepPairs(userId); + } while (_sweepRequested); + } finally { + _sweeping = false; + } } /// Drain текущего снапшота `pending` — явный триггер при обратном включении diff --git a/lib/src/features/notification_parsing/application/rules_controller.dart b/lib/src/features/notification_parsing/application/rules_controller.dart index fc98967..ceea11b 100644 --- a/lib/src/features/notification_parsing/application/rules_controller.dart +++ b/lib/src/features/notification_parsing/application/rules_controller.dart @@ -19,18 +19,21 @@ Future parseRuleById(Ref ref, String id) => ref.watch(parseRulesRepositoryProvider).findById(id); /// Live-превью (§9.3): сообщения за 30 дней, совпадающие с паттерном. +/// [packageName] скоупит превью до приложения правила (null — без фильтра). @riverpod Future> ruleMatchPreview( Ref ref, String userId, String pattern, MatchMode mode, + String? packageName, ) async { if (pattern.trim().isEmpty) return const []; final since = DateTime.now().subtract(const Duration(days: 30)); final messages = await ref.watch(rawMessagesRepositoryProvider).recentByUser(userId, since); return messages + .where((m) => packageName == null || m.packageName == packageName) .where((m) => patternMatches(pattern, mode, body: m.body)) .toList(); } @@ -43,6 +46,7 @@ class RulesController extends _$RulesController { Future create({ required String userId, + required String packageName, required ParseRuleKind kind, required MatchMode matchMode, required String pattern, @@ -55,6 +59,7 @@ class RulesController extends _$RulesController { try { final rule = await ref.read(parseRulesRepositoryProvider).create( userId: userId, + packageName: packageName, kind: kind, matchMode: matchMode, pattern: pattern, diff --git a/lib/src/features/notification_parsing/application/source_apps_controller.dart b/lib/src/features/notification_parsing/application/source_apps_controller.dart index f46eb28..6245c91 100644 --- a/lib/src/features/notification_parsing/application/source_apps_controller.dart +++ b/lib/src/features/notification_parsing/application/source_apps_controller.dart @@ -38,6 +38,9 @@ class SourceAppsController extends _$SourceAppsController { Future setEnabled(String id, {required bool enabled}) => ref.read(sourceAppsRepositoryProvider).setEnabled(id, enabled: enabled); + Future setSelfMerchant(String id, {required bool value}) => + ref.read(sourceAppsRepositoryProvider).setSelfMerchant(id, value: value); + Future delete(String id) => ref.read(sourceAppsRepositoryProvider).deleteById(id); } diff --git a/lib/src/features/notification_parsing/data/deepseek/ai_prompts.dart b/lib/src/features/notification_parsing/data/deepseek/ai_prompts.dart index 511ceeb..c4c6e39 100644 --- a/lib/src/features/notification_parsing/data/deepseek/ai_prompts.dart +++ b/lib/src/features/notification_parsing/data/deepseek/ai_prompts.dart @@ -38,6 +38,17 @@ String buildSystemPrompt({required List categoryNames}) { - "counterpartyPhone": телефон контрагента (для переводов по СБП), либо null. - "dateTime": ISO-8601 дата-время операции из текста, либо null. - "kind": одно из "purchase" | "refund" | "transfer_out" | "transfer_in" | "fee" | "balance" | "other". + +Правила выбора "kind" и "type". "type" — экономический смысл для бюджета, +"kind" — механика операции; жёсткой связки kind=transfer_* → type=transfer НЕТ: +- "transfer_out" — исходящий перевод денег («вы перевели», «перевод по СБП», + «перевод на карту/счёт»), НЕ оплата товара/услуги у продавца. type="expense". +- "transfer_in" — входящее зачисление переводом от физлица или со своего счёта + («N перевёл(а) вам», «Пополнение с карты *1234», «перевод из банка»). + type="income". +- Зарплата, соцвыплаты, проценты, кэшбек и прочие зачисления от организаций — + это НЕ переводы: kind="other", type="income". +- Оплата покупки — kind="purchase", type="expense". - "categorySuggestion": наиболее подходящая категория из списка пользователя (точное название из списка) или null, если ничего не подходит. diff --git a/lib/src/features/notification_parsing/data/drift/daos/parse_rules_dao.dart b/lib/src/features/notification_parsing/data/drift/daos/parse_rules_dao.dart index 2aaa3a2..03ed301 100644 --- a/lib/src/features/notification_parsing/data/drift/daos/parse_rules_dao.dart +++ b/lib/src/features/notification_parsing/data/drift/daos/parse_rules_dao.dart @@ -23,11 +23,17 @@ class ParseRulesDao extends DatabaseAccessor Future> getByUser(String userId) => (select(parseRulesTable)..where((t) => t.userId.equals(userId))).get(); - /// Только активные правила — для pipeline (rule_lookup). - Future> getEnabledByUser(String userId) => + /// Активные правила приложения [packageName] — для pipeline (rule_lookup). + /// NULL-строки (легаси без привязки) матчатся в любом приложении. + Future> getEnabledForApp( + String userId, + String packageName, + ) => (select(parseRulesTable) ..where((t) => - t.userId.equals(userId) & t.enabled.equals(true)) + t.userId.equals(userId) & + t.enabled.equals(true) & + (t.packageName.equals(packageName) | t.packageName.isNull())) ..orderBy([ (t) => OrderingTerm.desc(t.priority), (t) => OrderingTerm.desc(t.matchCount), diff --git a/lib/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart b/lib/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart index 61a9033..b2154b8 100644 --- a/lib/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart +++ b/lib/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart @@ -1,11 +1,13 @@ import 'package:drift/drift.dart'; import '../../../../../core/database/app_database.dart'; +import '../../../../../core/database/converters/enum_converters.dart'; +import '../../../../../core/database/tables/transactions_table.dart'; import '../tables/raw_messages_table.dart'; import '../../../domain/enums.dart'; part 'raw_messages_dao.g.dart'; -@DriftAccessor(tables: [RawMessagesTable]) +@DriftAccessor(tables: [RawMessagesTable, TransactionsTable]) class RawMessagesDao extends DatabaseAccessor with _$RawMessagesDaoMixin { RawMessagesDao(super.db); @@ -43,6 +45,17 @@ class RawMessagesDao extends DatabaseAccessor ..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])) .watch(); + /// Поток половинок переводов, ждущих пару, — триггер sweep и перевзвод + /// таймера дедлайна в ParsingWorker. Порядок — по receivedAt, чтобы sweep + /// матчил детерминированно (старшая половинка первой). + Stream> watchWaitingPair(String userId) => + (select(rawMessagesTable) + ..where((t) => + t.userId.equals(userId) & + t.status.equalsValue(RawMessageStatus.waitingPair)) + ..orderBy([(t) => OrderingTerm.asc(t.receivedAt)])) + .watch(); + /// Поток всех сообщений пользователя — для экрана «Журнал парсинга». /// Включает любые статусы (`applied`/`ignored`/`failed`/…), новые сверху. Stream> watchAll(String userId, {int limit = 200}) => @@ -65,6 +78,39 @@ class RawMessagesDao extends DatabaseAccessor .map((rows) => rows.isEmpty ? 0 : (rows.first.data['c'] as int? ?? 0)); } + /// Частые категории подтверждённых транзакций из уведомлений приложения + /// [packageName] — для чипов быстрого выбора на карточке Inbox. Порядок: + /// по числу транзакций, при равенстве — по свежести. + Stream> watchTopCategoryIdsForPackage( + String userId, + String packageName, + TransactionType type, { + int limit = 4, + }) { + final query = customSelect( + ''' +SELECT t.category_id AS cat +FROM transactions t +JOIN raw_messages m ON m.id = t.raw_message_id +WHERE t.user_id = ?1 AND m.package_name = ?2 AND t.type = ?3 + AND t.category_id IS NOT NULL +GROUP BY t.category_id +ORDER BY COUNT(*) DESC, MAX(t.date) DESC +LIMIT ?4 +''', + variables: [ + Variable(userId), + Variable(packageName), + Variable(const TransactionTypeConverter().toSql(type)), + Variable(limit), + ], + readsFrom: {rawMessagesTable, transactionsTable}, + ); + return query + .watch() + .map((rows) => rows.map((r) => r.data['cat'] as String).toList()); + } + // ── Lookups ──────────────────────────────────────────────────────────────── Future findById(String id) => @@ -96,6 +142,22 @@ class RawMessagesDao extends DatabaseAccessor ..limit(1)) .getSingleOrNull(); + /// Сообщения в [statuses] с `receivedAt ∈ [from, to]` — кандидаты в + /// контрпартнёры пары для sweep (фильтр по kind делается в Dart по draftJson). + Future> findRecentByStatuses( + String userId, + DateTime from, + DateTime to, + List statuses, + ) => + (select(rawMessagesTable) + ..where((t) => + t.userId.equals(userId) & + t.receivedAt.isBetweenValues(from, to) & + t.status.isInValues(statuses)) + ..orderBy([(t) => OrderingTerm.asc(t.receivedAt)])) + .get(); + // ── Mutations ────────────────────────────────────────────────────────────── Future insert(RawMessagesTableCompanion companion) => @@ -140,6 +202,58 @@ class RawMessagesDao extends DatabaseAccessor ), ); + /// Паркует transfer-половинку в ожидание пары: draft сохранён, AI повторно + /// не зовётся; sweep либо склеит её, либо релизнет в Inbox по [deadline]. + Future holdForPairing({ + required String id, + required String draftJson, + required DateTime deadline, + int? confidenceAmount, + int? confidenceAccount, + int? confidenceType, + int? confidenceMerchant, + int? confidenceCategory, + }) => + (update(rawMessagesTable)..where((t) => t.id.equals(id))).write( + RawMessagesTableCompanion( + status: const Value(RawMessageStatus.waitingPair), + draftJson: Value(draftJson), + pairDeadline: Value(deadline), + confidenceAmount: Value(confidenceAmount), + confidenceAccount: Value(confidenceAccount), + confidenceType: Value(confidenceType), + confidenceMerchant: Value(confidenceMerchant), + confidenceCategory: Value(confidenceCategory), + ), + ); + + /// Связывает половинку с парой ([pairedWithId]) и, опционально, меняет + /// статус (secondary → paired). Дедлайн ожидания при этом снимается. + Future setPairing(String id, String pairedWithId, + {RawMessageStatus? status}) => + (update(rawMessagesTable)..where((t) => t.id.equals(id))).write( + RawMessagesTableCompanion( + pairedWithId: Value(pairedWithId), + pairDeadline: const Value(null), + status: status == null ? const Value.absent() : Value(status), + ), + ); + + /// Расклейка: снимает связь с парой (и дедлайн, если оставался). + Future clearPairing(String id) => + (update(rawMessagesTable)..where((t) => t.id.equals(id))).write( + const RawMessagesTableCompanion( + pairedWithId: Value(null), + pairDeadline: Value(null), + ), + ); + + /// Откат доклейки: отвязка от транзакции (статус меняет вызывающий). + Future unlinkTransaction(String id) => + (update(rawMessagesTable)..where((t) => t.id.equals(id))).write( + const RawMessagesTableCompanion(transactionId: Value(null)), + ); + /// Привязка к созданной транзакции (auto-apply). Future linkTransaction(String id, String transactionId) => (update(rawMessagesTable)..where((t) => t.id.equals(id))).write( diff --git a/lib/src/features/notification_parsing/data/drift/daos/source_apps_dao.dart b/lib/src/features/notification_parsing/data/drift/daos/source_apps_dao.dart index 6bc9914..d4b24d9 100644 --- a/lib/src/features/notification_parsing/data/drift/daos/source_apps_dao.dart +++ b/lib/src/features/notification_parsing/data/drift/daos/source_apps_dao.dart @@ -44,6 +44,10 @@ class SourceAppsDao extends DatabaseAccessor (update(sourceAppsTable)..where((t) => t.id.equals(id))) .write(SourceAppsTableCompanion(enabled: Value(enabled))); + Future setSelfMerchant(String id, {required bool value}) => + (update(sourceAppsTable)..where((t) => t.id.equals(id))) + .write(SourceAppsTableCompanion(selfMerchant: Value(value))); + Future deleteById(String id) => (delete(sourceAppsTable)..where((t) => t.id.equals(id))).go(); } diff --git a/lib/src/features/notification_parsing/data/drift/daos/transfer_pairing_blocklist_dao.dart b/lib/src/features/notification_parsing/data/drift/daos/transfer_pairing_blocklist_dao.dart new file mode 100644 index 0000000..e13bc75 --- /dev/null +++ b/lib/src/features/notification_parsing/data/drift/daos/transfer_pairing_blocklist_dao.dart @@ -0,0 +1,22 @@ +import 'package:drift/drift.dart'; +import '../../../../../core/database/app_database.dart'; +import '../tables/transfer_pairing_blocklist_table.dart'; + +part 'transfer_pairing_blocklist_dao.g.dart'; + +@DriftAccessor(tables: [TransferPairingBlocklistTable]) +class TransferPairingBlocklistDao extends DatabaseAccessor + with _$TransferPairingBlocklistDaoMixin { + TransferPairingBlocklistDao(super.db); + + Future insertEntry(TransferPairingBlocklistTableCompanion companion) => + into(transferPairingBlocklistTable).insert(companion); + + Future contains(String userId, String signature) async { + final row = await (select(transferPairingBlocklistTable) + ..where((t) => t.userId.equals(userId) & t.signature.equals(signature)) + ..limit(1)) + .getSingleOrNull(); + return row != null; + } +} diff --git a/lib/src/features/notification_parsing/data/drift/tables/parse_rules_table.dart b/lib/src/features/notification_parsing/data/drift/tables/parse_rules_table.dart index b602aeb..ae22c89 100644 --- a/lib/src/features/notification_parsing/data/drift/tables/parse_rules_table.dart +++ b/lib/src/features/notification_parsing/data/drift/tables/parse_rules_table.dart @@ -20,6 +20,12 @@ class ParseRulesTable extends Table { TextColumn get userId => text().references(UsersTable, #id, onDelete: KeyAction.cascade)(); + /// Приложение-источник, к которому привязано правило (per-app scope). + /// Nullable в SQL (упрощает миграцию), но все пути создания обязаны + /// передавать значение; NULL-строки матчатся в любом приложении (легаси). + /// Без FK на source_apps — зеркалим паттерн account_bindings. + TextColumn get packageName => text().nullable()(); + /// merchantToCategory | senderToAccount | ignore TextColumn get kind => text().map(const ParseRuleKindConverter())(); diff --git a/lib/src/features/notification_parsing/data/drift/tables/raw_messages_table.dart b/lib/src/features/notification_parsing/data/drift/tables/raw_messages_table.dart index 30e3af5..d0a6e8b 100644 --- a/lib/src/features/notification_parsing/data/drift/tables/raw_messages_table.dart +++ b/lib/src/features/notification_parsing/data/drift/tables/raw_messages_table.dart @@ -49,6 +49,14 @@ class RawMessagesTable extends Table { .references(TransactionsTable, #id, onDelete: KeyAction.setNull) .nullable()(); + /// id второй половинки склеенного перевода (transfer pairing). + /// У primary указывает на secondary и наоборот. Null — вне пары. + TextColumn get pairedWithId => text().nullable()(); + + /// Дедлайн ожидания пары для статуса `waitingPair`: по истечении sweep + /// релизит сообщение в Inbox одиночной карточкой. + DateTimeColumn get pairDeadline => dateTime().nullable()(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); @override diff --git a/lib/src/features/notification_parsing/data/drift/tables/source_apps_table.dart b/lib/src/features/notification_parsing/data/drift/tables/source_apps_table.dart index b73da30..e26d1c5 100644 --- a/lib/src/features/notification_parsing/data/drift/tables/source_apps_table.dart +++ b/lib/src/features/notification_parsing/data/drift/tables/source_apps_table.dart @@ -21,6 +21,12 @@ class SourceAppsTable extends Table { TextColumn get displayName => text().nullable()(); BoolColumn get enabled => boolean().withDefault(const Constant(true))(); + + /// Уведомления не называют продавца: мерчант — само приложение (Ozon, + /// маркетплейсы). Pipeline глушит AI-подсказку категории и не предлагает + /// merchant→category правило для таких источников. + BoolColumn get selfMerchant => boolean().withDefault(const Constant(false))(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); @override diff --git a/lib/src/features/notification_parsing/data/mappers/parse_rule_mapper.dart b/lib/src/features/notification_parsing/data/mappers/parse_rule_mapper.dart index d87bd2b..b70ffd2 100644 --- a/lib/src/features/notification_parsing/data/mappers/parse_rule_mapper.dart +++ b/lib/src/features/notification_parsing/data/mappers/parse_rule_mapper.dart @@ -6,6 +6,7 @@ extension ParseRuleMapper on ParseRulesTableData { ParseRule toDomain() => ParseRule( id: id, userId: userId, + packageName: packageName, kind: kind, matchMode: matchMode, pattern: pattern, diff --git a/lib/src/features/notification_parsing/data/mappers/raw_message_mapper.dart b/lib/src/features/notification_parsing/data/mappers/raw_message_mapper.dart index 36c1489..a6aaa31 100644 --- a/lib/src/features/notification_parsing/data/mappers/raw_message_mapper.dart +++ b/lib/src/features/notification_parsing/data/mappers/raw_message_mapper.dart @@ -22,6 +22,8 @@ extension RawMessageMapper on RawMessagesTableData { confidenceMerchant: confidenceMerchant, confidenceCategory: confidenceCategory, transactionId: transactionId, + pairedWithId: pairedWithId, + pairDeadline: pairDeadline, createdAt: createdAt, ); } diff --git a/lib/src/features/notification_parsing/data/mappers/source_app_mapper.dart b/lib/src/features/notification_parsing/data/mappers/source_app_mapper.dart index f6a4a23..4e6c5a3 100644 --- a/lib/src/features/notification_parsing/data/mappers/source_app_mapper.dart +++ b/lib/src/features/notification_parsing/data/mappers/source_app_mapper.dart @@ -9,6 +9,7 @@ extension SourceAppMapper on SourceAppsTableData { packageName: packageName, displayName: displayName, enabled: enabled, + selfMerchant: selfMerchant, createdAt: createdAt, ); } diff --git a/lib/src/features/notification_parsing/data/parser/draft_codec.dart b/lib/src/features/notification_parsing/data/parser/draft_codec.dart index 51c7cf1..346ae5e 100644 --- a/lib/src/features/notification_parsing/data/parser/draft_codec.dart +++ b/lib/src/features/notification_parsing/data/parser/draft_codec.dart @@ -17,6 +17,10 @@ class DraftBundle { required this.draft, this.suggestion, this.failedChecks = const {}, + this.accountTrusted = false, + this.pairedRawMessageId, + this.preMerge, + this.mergeUndo, }); final ParseDraft draft; @@ -25,18 +29,43 @@ class DraftBundle { /// Gate-проверки, не пройденные при отправке в Inbox — для строки /// «почему не автоматически» в карточке/журнале. final Set failedChecks; + + /// Снапшот `AccountResolution.trusted` на момент парсинга — sweep решает + /// по нему допустимость доклейки (resolution к тому моменту уже недоступен). + final bool accountTrusted; + + /// id второй половинки склеенного перевода. != null → merged-карточка + /// «Перевод между счетами» (или доклеенная половинка при [mergeUndo]). + final String? pairedRawMessageId; + + /// Снапшот полей primary-draft, перезаписанных склейкой + /// (`{'type', 'categoryId'}`) — для восстановления при расклейке из Inbox. + final Map? preMerge; + + /// Снапшот полей транзакции до доклейки (`{'txId', 'prevType', + /// 'prevAccountId', 'prevCategoryId', 'prevTransferToAccountId'}`) — + /// для отката доклеенного перевода из журнала. + final Map? mergeUndo; } String encodeDraftBundle( ParseDraft draft, RuleSuggestion? suggestion, { Set failedChecks = const {}, + bool accountTrusted = false, + String? pairedRawMessageId, + Map? preMerge, + Map? mergeUndo, }) { return jsonEncode({ 'draft': _draftToJson(draft), if (suggestion != null) 'suggestion': _suggestionToJson(suggestion), if (failedChecks.isNotEmpty) 'failedChecks': failedChecks.map((c) => c.name).toList(), + if (accountTrusted) 'accountTrusted': true, + 'pairedRawMessageId': ?pairedRawMessageId, + 'preMerge': ?preMerge, + 'mergeUndo': ?mergeUndo, }); } @@ -50,6 +79,10 @@ DraftBundle? decodeDraftBundle(String? json) { draft: _draftFromJson(draftMap), suggestion: suggMap == null ? null : _suggestionFromJson(suggMap), failedChecks: _failedChecksFromJson(map['failedChecks']), + accountTrusted: map['accountTrusted'] as bool? ?? false, + pairedRawMessageId: map['pairedRawMessageId'] as String?, + preMerge: (map['preMerge'] as Map?), + mergeUndo: (map['mergeUndo'] as Map?), ); } diff --git a/lib/src/features/notification_parsing/data/parser/rule_lookup.dart b/lib/src/features/notification_parsing/data/parser/rule_lookup.dart index f087d99..2cee2ed 100644 --- a/lib/src/features/notification_parsing/data/parser/rule_lookup.dart +++ b/lib/src/features/notification_parsing/data/parser/rule_lookup.dart @@ -75,26 +75,6 @@ ParseRule? findSenderRule( return matches.isEmpty ? null : matches.first; } -/// Ищет активный маркер `mixedMerchant` для мерчанта (категория варьируется). -/// -/// Наличие такого правила подавляет предложение merchant→category-правила в -/// Inbox (§9.1): pipeline не строит `RuleSuggestion`, и карточка показывает -/// «подтвердить разово» вместо кнопки «Создать правило». -ParseRule? findMixedMerchantRule( - List rules, { - required String body, - String? merchantRaw, -}) { - final matches = rules - .where((r) => - r.enabled && - r.kind == ParseRuleKind.mixedMerchant && - ruleMatches(r, body: body, merchantRaw: merchantRaw)) - .toList() - ..sort(_bySpecificity); - return matches.isEmpty ? null : matches.first; -} - /// Ищет активное правило-исключение (kind=ignore) для сообщения. ParseRule? findIgnoreRule( List rules, { diff --git a/lib/src/features/notification_parsing/data/parser/transfer_pair_matcher.dart b/lib/src/features/notification_parsing/data/parser/transfer_pair_matcher.dart new file mode 100644 index 0000000..f44bfb1 --- /dev/null +++ b/lib/src/features/notification_parsing/data/parser/transfer_pair_matcher.dart @@ -0,0 +1,71 @@ +import '../../domain/entities/parse_draft.dart'; +import '../../domain/enums.dart'; + +/// Чистые функции матчинга половинок перевода (transfer pairing). +/// +/// Пара = два уведомления о списании/зачислении одного перевода между своими +/// счетами. Асинхронные проверки (blocklist) делает вызывающий код +/// (`ParsingPipeline.sweepPairs`), здесь — только детерминированные предикаты. + +/// Окно матчинга и ожидания пары: |receivedAt₁ − receivedAt₂| ≤ 5 мин; +/// столько же живёт статус `waitingPair` до релиза в Inbox. +const Duration kPairingWindow = Duration(minutes: 5); + +/// Кандидат в пейринг: draft с гранулярным kind перевода. kind=null и все +/// прочие виды (purchase, fee, …) идут обычным путём. +bool isTransferHalf(ParseDraft draft) => + draft.kind == TxKind.transferOut || draft.kind == TxKind.transferIn; + +/// Предикат пары. [aReceivedAt]/[bReceivedAt] — времена получения сообщений +/// (в draft времени может не быть). Blocklist-сигнатуру проверяет вызывающий. +bool isPair({ + required ParseDraft a, + required DateTime aReceivedAt, + required ParseDraft b, + required DateTime bReceivedAt, +}) { + // Противоположные kind'ы (out ↔ in). + final opposite = + (a.kind == TxKind.transferOut && b.kind == TxKind.transferIn) || + (a.kind == TxKind.transferIn && b.kind == TxKind.transferOut); + if (!opposite) return false; + + // v1: суммы равны точно (без комиссий/конвертаций), валюты совпадают. + if (a.amount != b.amount) return false; + if (a.currency != b.currency) return false; + + if (aReceivedAt.difference(bReceivedAt).abs() > kPairingWindow) return false; + + // Перевод самому себе на тот же счёт не бывает: если оба счёта разрешены + // и совпали — это не пара. Одинаковый packageName допустим + // (внутрибанковский перевод «между счетами»). + if (a.accountId != null && b.accountId != null && a.accountId == b.accountId) { + return false; + } + + return true; +} + +/// Из [candidates] выбирает ближайшего по `receivedAt` к [target]. +/// Кандидаты — пары (значение, receivedAt); список не должен быть пустым. +T pickNearest( + List<(T, DateTime)> candidates, + DateTime target, +) { + assert(candidates.isNotEmpty); + var best = candidates.first; + var bestDelta = best.$2.difference(target).abs(); + for (final c in candidates.skip(1)) { + final delta = c.$2.difference(target).abs(); + if (delta < bestDelta) { + best = c; + bestDelta = delta; + } + } + return best.$1; +} + +/// Сигнатура пары сообщений для blocklist: порядконезависимая, чтобы +/// расклейка блокировала повторный матч с любой стороны. +String pairSignature(String idA, String idB) => + ([idA, idB]..sort()).join('|'); diff --git a/lib/src/features/notification_parsing/data/repositories/parse_rules_repository_impl.dart b/lib/src/features/notification_parsing/data/repositories/parse_rules_repository_impl.dart index 97f163a..64f4d91 100644 --- a/lib/src/features/notification_parsing/data/repositories/parse_rules_repository_impl.dart +++ b/lib/src/features/notification_parsing/data/repositories/parse_rules_repository_impl.dart @@ -19,8 +19,13 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository { .map((rows) => rows.map((r) => r.toDomain()).toList()); @override - Future> getEnabledByUser(String userId) async => - (await _dao.getEnabledByUser(userId)).map((r) => r.toDomain()).toList(); + Future> getEnabledForApp( + String userId, + String packageName, + ) async => + (await _dao.getEnabledForApp(userId, packageName)) + .map((r) => r.toDomain()) + .toList(); @override Future> getByUser(String userId) async => @@ -33,6 +38,7 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository { @override Future create({ required String userId, + required String packageName, required ParseRuleKind kind, required MatchMode matchMode, required String pattern, @@ -47,6 +53,7 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository { ParseRulesTableCompanion.insert( id: id, userId: userId, + packageName: Value(packageName), kind: kind, pattern: pattern, matchMode: Value(matchMode), @@ -65,6 +72,7 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository { Future update(ParseRule rule) => _dao.updateRow( ParseRulesTableCompanion( id: Value(rule.id), + packageName: Value(rule.packageName), kind: Value(rule.kind), matchMode: Value(rule.matchMode), pattern: Value(rule.pattern), diff --git a/lib/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart b/lib/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart index 31960b3..9581074 100644 --- a/lib/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart +++ b/lib/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart @@ -2,6 +2,7 @@ import 'package:drift/drift.dart'; import 'package:uuid/uuid.dart'; import '../../../../core/database/app_database.dart'; +import '../../../../core/database/converters/enum_converters.dart'; import '../../domain/entities/raw_message.dart'; import '../../domain/enums.dart'; import '../../domain/repositories/raw_messages_repository.dart'; @@ -32,9 +33,19 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository { Stream> watchAll(String userId) => _dao.watchAll(userId).map((rows) => rows.map((r) => r.toDomain()).toList()); + @override + Stream> watchWaitingPair(String userId) => _dao + .watchWaitingPair(userId) + .map((rows) => rows.map((r) => r.toDomain()).toList()); + @override Stream watchInboxCount(String userId) => _dao.watchInboxCount(userId); + @override + Stream> watchTopCategoryIds( + String userId, String packageName, TransactionType type) => + _dao.watchTopCategoryIdsForPackage(userId, packageName, type); + @override Future findById(String id) async => (await _dao.findById(id))?.toDomain(); @@ -56,6 +67,17 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository { .map((r) => r.toDomain()) .toList(); + @override + Future> findRecentByStatuses( + String userId, + DateTime from, + DateTime to, + List statuses, + ) async => + (await _dao.findRecentByStatuses(userId, from, to, statuses)) + .map((r) => r.toDomain()) + .toList(); + @override Future insertIncoming({ required String userId, @@ -130,6 +152,39 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository { Future linkTransaction(String id, String transactionId) => _dao.linkTransaction(id, transactionId); + @override + Future holdForPairing({ + required String id, + required String draftJson, + required DateTime deadline, + int? confidenceAmount, + int? confidenceAccount, + int? confidenceType, + int? confidenceMerchant, + int? confidenceCategory, + }) => + _dao.holdForPairing( + id: id, + draftJson: draftJson, + deadline: deadline, + confidenceAmount: confidenceAmount, + confidenceAccount: confidenceAccount, + confidenceType: confidenceType, + confidenceMerchant: confidenceMerchant, + confidenceCategory: confidenceCategory, + ); + + @override + Future setPairing(String id, String pairedWithId, + {RawMessageStatus? status}) => + _dao.setPairing(id, pairedWithId, status: status); + + @override + Future clearPairing(String id) => _dao.clearPairing(id); + + @override + Future unlinkTransaction(String id) => _dao.unlinkTransaction(id); + @override Future incrementParseAttempts(String id) => _dao.incrementParseAttempts(id); diff --git a/lib/src/features/notification_parsing/data/repositories/source_apps_repository_impl.dart b/lib/src/features/notification_parsing/data/repositories/source_apps_repository_impl.dart index 7dccaac..0f035b1 100644 --- a/lib/src/features/notification_parsing/data/repositories/source_apps_repository_impl.dart +++ b/lib/src/features/notification_parsing/data/repositories/source_apps_repository_impl.dart @@ -42,10 +42,18 @@ class SourceAppsRepositoryImpl implements SourceAppsRepository { return row!.toDomain(); } + @override + Future findByPackageName(String userId, String packageName) async => + (await _dao.findByPackageName(userId, packageName))?.toDomain(); + @override Future setEnabled(String id, {required bool enabled}) => _dao.setEnabled(id, enabled: enabled); + @override + Future setSelfMerchant(String id, {required bool value}) => + _dao.setSelfMerchant(id, value: value); + @override Future deleteById(String id) => _dao.deleteById(id); } diff --git a/lib/src/features/notification_parsing/data/repositories/transfer_pairing_blocklist_repository_impl.dart b/lib/src/features/notification_parsing/data/repositories/transfer_pairing_blocklist_repository_impl.dart new file mode 100644 index 0000000..157eac7 --- /dev/null +++ b/lib/src/features/notification_parsing/data/repositories/transfer_pairing_blocklist_repository_impl.dart @@ -0,0 +1,28 @@ +import 'package:uuid/uuid.dart'; + +import '../../../../core/database/app_database.dart'; +import '../../domain/repositories/transfer_pairing_blocklist_repository.dart'; +import '../drift/daos/transfer_pairing_blocklist_dao.dart'; + +class TransferPairingBlocklistRepositoryImpl + implements TransferPairingBlocklistRepository { + const TransferPairingBlocklistRepositoryImpl(this._dao); + final TransferPairingBlocklistDao _dao; + + @override + Future add(String userId, String signature) async { + // Идемпотентно: повторная расклейка той же пары не плодит дублей. + if (await _dao.contains(userId, signature)) return; + await _dao.insertEntry( + TransferPairingBlocklistTableCompanion.insert( + id: const Uuid().v4(), + userId: userId, + signature: signature, + ), + ); + } + + @override + Future contains(String userId, String signature) => + _dao.contains(userId, signature); +} diff --git a/lib/src/features/notification_parsing/domain/entities/parse_rule.dart b/lib/src/features/notification_parsing/domain/entities/parse_rule.dart index 8aa5d7c..3c12ea6 100644 --- a/lib/src/features/notification_parsing/domain/entities/parse_rule.dart +++ b/lib/src/features/notification_parsing/domain/entities/parse_rule.dart @@ -20,6 +20,10 @@ abstract class ParseRule with _$ParseRule { const factory ParseRule({ required String id, required String userId, + + /// Приложение-источник, к которому привязано правило. null — легаси + /// (глобальное правило, матчится в любом приложении). + String? packageName, required ParseRuleKind kind, required MatchMode matchMode, required String pattern, diff --git a/lib/src/features/notification_parsing/domain/entities/raw_message.dart b/lib/src/features/notification_parsing/domain/entities/raw_message.dart index 93a1841..71eb6e5 100644 --- a/lib/src/features/notification_parsing/domain/entities/raw_message.dart +++ b/lib/src/features/notification_parsing/domain/entities/raw_message.dart @@ -42,6 +42,13 @@ abstract class RawMessage with _$RawMessage { /// FK на transactions.id — заполняется когда статус applied. String? transactionId, + + /// id второй половинки склеенного перевода (у primary — secondary и + /// наоборот). Null — вне пары. + String? pairedWithId, + + /// Дедлайн ожидания пары (только для статуса waitingPair). + DateTime? pairDeadline, required DateTime createdAt, }) = _RawMessage; } diff --git a/lib/src/features/notification_parsing/domain/entities/source_app.dart b/lib/src/features/notification_parsing/domain/entities/source_app.dart index 7a12b80..c86efec 100644 --- a/lib/src/features/notification_parsing/domain/entities/source_app.dart +++ b/lib/src/features/notification_parsing/domain/entities/source_app.dart @@ -14,6 +14,9 @@ abstract class SourceApp with _$SourceApp { required String packageName, String? displayName, @Default(true) bool enabled, + + /// Уведомления не называют продавца: мерчант — само приложение (Ozon). + @Default(false) bool selfMerchant, required DateTime createdAt, }) = _SourceApp; } diff --git a/lib/src/features/notification_parsing/domain/enums.dart b/lib/src/features/notification_parsing/domain/enums.dart index c363aa0..618cc15 100644 --- a/lib/src/features/notification_parsing/domain/enums.dart +++ b/lib/src/features/notification_parsing/domain/enums.dart @@ -15,6 +15,14 @@ enum RawMessageStatus { applied, ignored, failed, + + /// Распарсенная transfer-половинка скрыто ждёт вторую половинку пары + /// (окно до `pairDeadline`); по таймауту уходит в Inbox одиночкой. + waitingPair, + + /// Вторичная половинка склеенного перевода: скрыта из Inbox, в журнале + /// показывается как «объединено в перевод». + paired, } // --------------------------------------------------------------------------- @@ -24,11 +32,6 @@ enum ParseRuleKind { merchantToCategory, senderToAccount, ignore, - - /// Маркер «у мерчанта категория варьируется» (Ozon, маркетплейсы): не задаёт - /// действия, а подавляет предложение merchant→category-правила в Inbox. - /// Создаётся явным действием пользователя «Категории различаются». - mixedMerchant, } // --------------------------------------------------------------------------- diff --git a/lib/src/features/notification_parsing/domain/repositories/parse_rules_repository.dart b/lib/src/features/notification_parsing/domain/repositories/parse_rules_repository.dart index 6d34395..dabf18d 100644 --- a/lib/src/features/notification_parsing/domain/repositories/parse_rules_repository.dart +++ b/lib/src/features/notification_parsing/domain/repositories/parse_rules_repository.dart @@ -7,8 +7,9 @@ abstract interface class ParseRulesRepository { /// Все правила пользователя — для экрана «Правила парсинга». Stream> watchByUser(String userId); - /// Только активные правила — для pipeline (rule_lookup). - Future> getEnabledByUser(String userId); + /// Активные правила приложения [packageName] (+ легаси-строки без + /// packageName) — для pipeline (rule_lookup). + Future> getEnabledForApp(String userId, String packageName); Future> getByUser(String userId); @@ -17,6 +18,7 @@ abstract interface class ParseRulesRepository { /// Создаёт правило (активно сразу, weight=1). Возвращает сохранённую сущность. Future create({ required String userId, + required String packageName, required ParseRuleKind kind, required MatchMode matchMode, required String pattern, diff --git a/lib/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart b/lib/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart index 2edd017..a7fc088 100644 --- a/lib/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart +++ b/lib/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart @@ -1,3 +1,4 @@ +import '../../../../core/database/converters/enum_converters.dart'; import '../entities/raw_message.dart'; import '../enums.dart'; @@ -19,9 +20,18 @@ abstract interface class RawMessagesRepository { /// Все сообщения пользователя (любой статус) — для «Журнала парсинга». Stream> watchAll(String userId); + /// Половинки переводов, ждущие пару (`waitingPair`) — триггер sweep + /// и таймера дедлайна в ParsingWorker. + Stream> watchWaitingPair(String userId); + /// Реактивный счётчик Inbox — для бэджа на Home. Stream watchInboxCount(String userId); + /// Частые категории подтверждённых транзакций из уведомлений приложения — + /// для чипов быстрого выбора на карточке Inbox (по частоте, затем свежести). + Stream> watchTopCategoryIds( + String userId, String packageName, TransactionType type); + Future findById(String id); /// Поиск дубля: совпадение [dedupHash] **и** `receivedAt` в пределах @@ -33,6 +43,15 @@ abstract interface class RawMessagesRepository { /// Сообщения за период (для live-превью правил). Future> recentByUser(String userId, DateTime since); + /// Сообщения в [statuses] с `receivedAt ∈ [from, to]` — кандидаты в + /// контрпартнёры пары для sweep. + Future> findRecentByStatuses( + String userId, + DateTime from, + DateTime to, + List statuses, + ); + /// Идемпотентная вставка нового уведомления. Если сообщение с тем же /// dedup-хэшем уже есть **в пределах дедуп-окна по [receivedAt]** — /// возвращает существующее, не создавая дубль. Тот же текст вне окна @@ -67,6 +86,30 @@ abstract interface class RawMessagesRepository { /// Привязка к созданной транзакции (status → applied). Future linkTransaction(String id, String transactionId); + /// Паркует transfer-половинку в ожидание пары (status → waitingPair, + /// draft сохранён, дедлайн взведён). + Future holdForPairing({ + required String id, + required String draftJson, + required DateTime deadline, + int? confidenceAmount, + int? confidenceAccount, + int? confidenceType, + int? confidenceMerchant, + int? confidenceCategory, + }); + + /// Связывает половинку с парой; [status] — опциональная смена статуса + /// (secondary → paired). + Future setPairing(String id, String pairedWithId, + {RawMessageStatus? status}); + + /// Расклейка: снимает связь с парой. + Future clearPairing(String id); + + /// Откат доклейки: отвязка от транзакции (статус меняет вызывающий). + Future unlinkTransaction(String id); + Future incrementParseAttempts(String id); /// Сброс сообщения на повторную обработку (status → pending, попытки → 0). diff --git a/lib/src/features/notification_parsing/domain/repositories/source_apps_repository.dart b/lib/src/features/notification_parsing/domain/repositories/source_apps_repository.dart index e7fd709..bac49be 100644 --- a/lib/src/features/notification_parsing/domain/repositories/source_apps_repository.dart +++ b/lib/src/features/notification_parsing/domain/repositories/source_apps_repository.dart @@ -9,6 +9,10 @@ abstract interface class SourceAppsRepository { /// Стрим, чтобы фильтр и native-синк реагировали на изменения allowlist. Stream> watchEnabledPackages(String userId); + /// Приложение по packageName — снапшот для pipeline (allowlist + флаг + /// [SourceApp.selfMerchant]). null = приложение не добавлено. + Future findByPackageName(String userId, String packageName); + /// Добавляет приложение (включено по умолчанию). Возвращает сущность. /// Если строка с таким packageName уже есть — возвращает существующую. Future add({ @@ -19,5 +23,8 @@ abstract interface class SourceAppsRepository { Future setEnabled(String id, {required bool enabled}); + /// Флаг «мерчант — само приложение» (Ozon, маркетплейсы). + Future setSelfMerchant(String id, {required bool value}); + Future deleteById(String id); } diff --git a/lib/src/features/notification_parsing/domain/repositories/transfer_pairing_blocklist_repository.dart b/lib/src/features/notification_parsing/domain/repositories/transfer_pairing_blocklist_repository.dart new file mode 100644 index 0000000..8d8630f --- /dev/null +++ b/lib/src/features/notification_parsing/domain/repositories/transfer_pairing_blocklist_repository.dart @@ -0,0 +1,10 @@ +/// Blocklist расклеенных пар переводов. +/// +/// v1: сигнатура — разовая, по конкретной паре сообщений +/// (`pairSignature(msgIdA, msgIdB)`), не вечный бан пары счетов. Расклейка +/// пишет сигнатуру, чтобы sweep не склеил ту же пару снова. +abstract interface class TransferPairingBlocklistRepository { + Future add(String userId, String signature); + + Future contains(String userId, String signature); +} diff --git a/lib/src/features/notification_parsing/presentation/screens/inbox_screen.dart b/lib/src/features/notification_parsing/presentation/screens/inbox_screen.dart index 79401c3..9ef3ccb 100644 --- a/lib/src/features/notification_parsing/presentation/screens/inbox_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/inbox_screen.dart @@ -79,31 +79,15 @@ class InboxScreen extends ConsumerWidget { top: false, child: Padding( padding: const EdgeInsets.fromLTRB(12, 4, 12, 8), - child: Row( - children: [ - Expanded( - child: FilledButton( - style: FilledButton.styleFrom(backgroundColor: p.accent), - onPressed: () => ref - .read(inboxControllerProvider.notifier) - .applyAll(userId), - child: Text(l10n.inboxApplyAll), - ), - ), - const SizedBox(width: 8), - Expanded( - child: OutlinedButton( - style: OutlinedButton.styleFrom( - foregroundColor: p.ink, - side: BorderSide(color: p.line), - ), - onPressed: () => ref - .read(inboxControllerProvider.notifier) - .hideAll(userId), - child: Text(l10n.inboxHideAll), - ), - ), - ], + child: SizedBox( + width: double.infinity, + child: FilledButton( + style: FilledButton.styleFrom(backgroundColor: p.accent), + onPressed: () => ref + .read(inboxControllerProvider.notifier) + .applyAll(userId), + child: Text(l10n.inboxApplyAll), + ), ), ), ), diff --git a/lib/src/features/notification_parsing/presentation/screens/parsing_log_screen.dart b/lib/src/features/notification_parsing/presentation/screens/parsing_log_screen.dart index 077813d..7bb616a 100644 --- a/lib/src/features/notification_parsing/presentation/screens/parsing_log_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/parsing_log_screen.dart @@ -38,7 +38,8 @@ class _ParsingLogScreenState extends ConsumerState { _LogFilter.waiting => m.status == RawMessageStatus.pendingAi || m.status == RawMessageStatus.pending || m.status == RawMessageStatus.parsing || - m.status == RawMessageStatus.parsed, + m.status == RawMessageStatus.parsed || + m.status == RawMessageStatus.waitingPair, _LogFilter.inbox => m.status == RawMessageStatus.inbox || m.status == RawMessageStatus.parsedPartial, _LogFilter.applied => m.status == RawMessageStatus.applied, @@ -268,6 +269,30 @@ class _LogRowState extends ConsumerState<_LogRow> { ), ), if (_expanded) _DetailPanel(message: message, bundle: bundle), + // Откат доклеенного перевода: кнопка живёт, пока половинка + // привязана к транзакции (после отката bundle теряет mergeUndo). + if (_expanded && + bundle?.mergeUndo != null && + message.transactionId != null) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + onPressed: () => ref + .read(inboxControllerProvider.notifier) + .unmergeApplied(userId: message.userId, message: message), + style: OutlinedButton.styleFrom( + foregroundColor: p.ink, + side: BorderSide(color: p.line), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + ), + icon: const Icon(Icons.link_off, size: 16), + label: Text(l10n.parsingUnmergeTransfer, + style: const TextStyle(fontSize: 13)), + ), + ), + ], if (canRetry) ...[ const SizedBox(height: 8), Align( @@ -593,5 +618,7 @@ class _StatusBadge extends StatelessWidget { RawMessageStatus.applied => l10n.parsingStatusApplied, RawMessageStatus.ignored => l10n.parsingStatusIgnored, RawMessageStatus.failed => l10n.parsingStatusFailed, + RawMessageStatus.waitingPair => l10n.parsingStatusWaitingPair, + RawMessageStatus.paired => l10n.parsingStatusPaired, }; } diff --git a/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart b/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart index 792bcf3..f517ab5 100644 --- a/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart @@ -75,6 +75,24 @@ class ParsingSettingsScreen extends ConsumerWidget { ), ], ), + const SizedBox(height: 16), + _Card( + children: [ + SwitchListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 14), + secondary: Icon(Icons.swap_horiz, color: p.ink2), + title: Text(l10n.parsingTransferPairingLabel, + style: TextStyle(fontSize: 14, color: p.ink)), + subtitle: Text(l10n.parsingTransferPairingHint, + style: TextStyle(fontSize: 12, color: p.ink2)), + value: settings?.transferPairingEnabled ?? true, + activeThumbColor: p.accent, + onChanged: settings == null + ? null + : (v) => controller.setTransferPairingEnabled(v), + ), + ], + ), if (defaultTargetPlatform == TargetPlatform.android) ...[ const SizedBox(height: 16), _Card( diff --git a/lib/src/features/notification_parsing/presentation/screens/rule_editor_screen.dart b/lib/src/features/notification_parsing/presentation/screens/rule_editor_screen.dart index 165e6f6..de22f4e 100644 --- a/lib/src/features/notification_parsing/presentation/screens/rule_editor_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/rule_editor_screen.dart @@ -13,12 +13,15 @@ import '../../../transactions/presentation/widgets/account_picker_sheet.dart'; import '../../../transactions/presentation/widgets/category_picker_sheet.dart'; import '../../../user/application/active_user_controller.dart'; import '../../application/rules_controller.dart'; +import '../../application/source_apps_controller.dart'; import '../../domain/entities/parse_rule.dart'; +import '../../domain/entities/source_app.dart'; import '../../domain/enums.dart'; /// Предзаполнение редактора при создании правила из Inbox. class RuleEditorPrefill { const RuleEditorPrefill({ + required this.packageName, required this.pattern, required this.merchantCanonical, this.categoryId, @@ -26,6 +29,7 @@ class RuleEditorPrefill { this.matchMode = MatchMode.contains, }); + final String packageName; final String pattern; final String merchantCanonical; final String? categoryId; @@ -36,6 +40,7 @@ class RuleEditorPrefill { /// Результат редактора в режиме compose (возвращается через pop в Inbox). class RuleEditorResult { const RuleEditorResult({ + required this.packageName, required this.kind, required this.matchMode, required this.pattern, @@ -45,6 +50,7 @@ class RuleEditorResult { this.priority = 0, }); + final String packageName; final ParseRuleKind kind; final MatchMode matchMode; final String pattern; @@ -72,6 +78,7 @@ class _RuleEditorScreenState extends ConsumerState { final _merchantCtrl = TextEditingController(); ParseRuleKind _kind = ParseRuleKind.merchantToCategory; MatchMode _matchMode = MatchMode.contains; + String? _packageName; String? _categoryId; String? _accountId; int _priority = 0; @@ -81,11 +88,16 @@ class _RuleEditorScreenState extends ConsumerState { bool get _isEdit => widget.ruleId != null; + /// Приложение выбирается только при создании «с нуля» из списка правил; + /// из Inbox (prefill) и при правке оно зафиксировано (read-only). + bool get _appIsPickable => !_isEdit && widget.prefill == null; + @override void initState() { super.initState(); final pf = widget.prefill; if (pf != null) { + _packageName = pf.packageName; _patternCtrl.text = pf.pattern; _merchantCtrl.text = pf.merchantCanonical; _matchMode = pf.matchMode; @@ -104,6 +116,7 @@ class _RuleEditorScreenState extends ConsumerState { void _hydrateFromRule(ParseRule rule) { if (_initialized) return; + _packageName = rule.packageName; _kind = rule.kind; _patternCtrl.text = rule.pattern; _merchantCtrl.text = rule.merchantCanonical ?? ''; @@ -116,11 +129,13 @@ class _RuleEditorScreenState extends ConsumerState { } bool get _isSender => _kind == ParseRuleKind.senderToAccount; - bool get _isMixed => _kind == ParseRuleKind.mixedMerchant; - /// Готовность к сохранению: для sender→account нужен счёт. + /// Готовность к сохранению: нужны приложение и (для sender→account) счёт. + /// При правке легаси-правила без packageName сохранение не блокируем. bool get _canSave => - _patternCtrl.text.trim().isNotEmpty && (!_isSender || _accountId != null); + _patternCtrl.text.trim().isNotEmpty && + (_isEdit || _packageName != null) && + (!_isSender || _accountId != null); Future _save(String userId, ParseRule? existing) async { final pattern = _patternCtrl.text.trim(); @@ -146,6 +161,7 @@ class _RuleEditorScreenState extends ConsumerState { } else { context.pop( RuleEditorResult( + packageName: _packageName!, kind: _kind, matchMode: _matchMode, pattern: pattern, @@ -184,6 +200,15 @@ class _RuleEditorScreenState extends ConsumerState { final accountName = _accountId == null ? null : accounts.where((a) => a.id == _accountId).firstOrNull?.name; + final sourceApps = ref.watch(sourceAppsListProvider(userId)).value ?? + const []; + final appLabel = _packageName == null + ? l10n.rulesUnknownApp + : sourceApps + .where((a) => a.packageName == _packageName) + .firstOrNull + ?.displayName ?? + _packageName!; return Scaffold( backgroundColor: p.paper, @@ -209,13 +234,59 @@ class _RuleEditorScreenState extends ConsumerState { // Выбор вида — только при создании из списка правил. Из Inbox // (prefill) композим всегда merchant→category: senderToAccount там // не применяется (inbox_controller создаёт правило этого вида). - if (!_isEdit && widget.prefill == null) ...[ + if (_appIsPickable) ...[ _KindSelector( kind: _kind, onChanged: (k) => setState(() => _kind = k), ), const SizedBox(height: 16), ], + // Приложение-источник: правило действует только внутри него. + // Выбирается при создании «с нуля»; из Inbox и при правке — read-only. + if (_appIsPickable) + if (sourceApps.isEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text(l10n.ruleEditorNoApps, + style: TextStyle(fontSize: 13, color: p.negative)), + ) + else ...[ + _FieldRow( + label: l10n.ruleEditorApp, + child: DropdownButton( + value: _packageName, + isExpanded: true, + hint: Text(l10n.ruleEditorAppPick, + textAlign: TextAlign.end, + style: TextStyle(fontSize: 14, color: p.ink2)), + underline: const SizedBox.shrink(), + alignment: AlignmentDirectional.centerEnd, + items: [ + for (final app in sourceApps) + DropdownMenuItem( + value: app.packageName, + child: Text(app.displayName ?? app.packageName, + style: TextStyle(fontSize: 14, color: p.ink)), + ), + ], + onChanged: (v) => setState(() => _packageName = v), + ), + ), + _Divider(color: p.line), + const SizedBox(height: 12), + ] + else ...[ + _FieldRow( + label: l10n.ruleEditorApp, + child: Text( + appLabel, + textAlign: TextAlign.end, + style: TextStyle(fontSize: 14, color: p.ink), + ), + ), + _Divider(color: p.line), + const SizedBox(height: 12), + ], Text(l10n.ruleEditorIfContains, style: TextStyle(fontSize: 13, color: p.ink2)), const SizedBox(height: 6), @@ -238,58 +309,53 @@ class _RuleEditorScreenState extends ConsumerState { onChanged: (m) => setState(() => _matchMode = m), ), const SizedBox(height: 18), - if (_isMixed) - Text(l10n.ruleKindMixedHint, - style: TextStyle(fontSize: 13, color: p.ink2, height: 1.3)), - if (!_isMixed) ...[ - Text(l10n.ruleEditorThen, - style: TextStyle(fontSize: 13, color: p.ink2)), - const SizedBox(height: 8), - if (!_isSender) ...[ - _FieldRow( - label: l10n.ruleEditorMerchant, - child: TextField( - controller: _merchantCtrl, - textAlign: TextAlign.end, - decoration: InputDecoration( - hintText: l10n.ruleEditorMerchantHint, - isDense: true, - border: InputBorder.none, - ), + Text(l10n.ruleEditorThen, + style: TextStyle(fontSize: 13, color: p.ink2)), + const SizedBox(height: 8), + if (!_isSender) ...[ + _FieldRow( + label: l10n.ruleEditorMerchant, + child: TextField( + controller: _merchantCtrl, + textAlign: TextAlign.end, + decoration: InputDecoration( + hintText: l10n.ruleEditorMerchantHint, + isDense: true, + border: InputBorder.none, ), ), - _Divider(color: p.line), - _PickerRow( - label: l10n.ruleEditorCategory, - value: categoryName ?? l10n.inboxNoCategory, - onTap: () async { - final id = await showCategoryPicker( - context, - userId: userId, - type: CategoryType.expense, - currentCategoryId: _categoryId, - ); - if (id != null) setState(() => _categoryId = id); - }, - ), - _Divider(color: p.line), - ], + ), + _Divider(color: p.line), _PickerRow( - label: l10n.ruleEditorAccount, - value: accountName ?? - (_isSender - ? l10n.ruleEditorAccountPick - : l10n.ruleEditorAccountUnchanged), + label: l10n.ruleEditorCategory, + value: categoryName ?? l10n.inboxNoCategory, onTap: () async { - final id = await showAccountPicker( + final id = await showCategoryPicker( context, userId: userId, - currentAccountId: _accountId, + type: CategoryType.expense, + currentCategoryId: _categoryId, ); - if (id != null) setState(() => _accountId = id); + if (id != null) setState(() => _categoryId = id); }, ), + _Divider(color: p.line), ], + _PickerRow( + label: l10n.ruleEditorAccount, + value: accountName ?? + (_isSender + ? l10n.ruleEditorAccountPick + : l10n.ruleEditorAccountUnchanged), + onTap: () async { + final id = await showAccountPicker( + context, + userId: userId, + currentAccountId: _accountId, + ); + if (id != null) setState(() => _accountId = id); + }, + ), const SizedBox(height: 12), _AdvancedToggle( expanded: _advanced, @@ -326,6 +392,7 @@ class _RuleEditorScreenState extends ConsumerState { userId: userId, pattern: _patternCtrl.text, mode: _matchMode, + packageName: _packageName, ), const SizedBox(height: 18), FilledButton( @@ -347,20 +414,23 @@ class _MatchesPreview extends ConsumerWidget { required this.userId, required this.pattern, required this.mode, + required this.packageName, }); final String userId; final String pattern; final MatchMode mode; + final String? packageName; @override Widget build(BuildContext context, WidgetRef ref) { final p = context.palette; final l10n = context.l10n; if (pattern.trim().isEmpty) return const SizedBox.shrink(); - final matches = - ref.watch(ruleMatchPreviewProvider(userId, pattern, mode)).value ?? - const []; + final matches = ref + .watch(ruleMatchPreviewProvider(userId, pattern, mode, packageName)) + .value ?? + const []; return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/src/features/notification_parsing/presentation/screens/rules_list_screen.dart b/lib/src/features/notification_parsing/presentation/screens/rules_list_screen.dart index 6ac458f..6a2649e 100644 --- a/lib/src/features/notification_parsing/presentation/screens/rules_list_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/rules_list_screen.dart @@ -11,7 +11,9 @@ import '../../../categories/application/categories_controller.dart'; import '../../../categories/domain/entities/category.dart'; import '../../../user/application/active_user_controller.dart'; import '../../application/rules_controller.dart'; +import '../../application/source_apps_controller.dart'; import '../../domain/entities/parse_rule.dart'; +import '../../domain/entities/source_app.dart'; import '../../domain/enums.dart'; import '../widgets/rule_card.dart'; import 'rule_editor_screen.dart'; @@ -56,6 +58,22 @@ class _RulesListScreenState extends ConsumerState { final accounts = ref.watch(accountsStreamProvider(userId)).value ?? const []; final accountById = {for (final a in accounts) a.id: a}; + final sourceApps = ref.watch(sourceAppsListProvider(userId)).value ?? + const []; + final displayNameByPackage = { + for (final a in sourceApps) a.packageName: a.displayName, + }; + + // Секции по приложению-источнику: заголовок = displayName ?? packageName + // (легаси-правила без привязки — в секцию «Неизвестное приложение»). + final groups = >{}; + for (final r in rules) { + final header = r.packageName == null + ? l10n.rulesUnknownApp + : displayNameByPackage[r.packageName] ?? r.packageName!; + groups.putIfAbsent(header, () => []).add(r); + } + final sortedHeaders = groups.keys.toList()..sort(); return Scaffold( backgroundColor: p.paper, @@ -105,20 +123,36 @@ class _RulesListScreenState extends ConsumerState { style: TextStyle(fontSize: 14, color: p.ink2)), ), ) - : ListView.separated( - itemCount: rules.length, - separatorBuilder: (_, _) => - Container(height: 1, color: p.line), - itemBuilder: (context, i) => RuleCard( - rule: rules[i], - categoryById: categoryById, - accountById: accountById, - onTap: () => - context.push(AppRoutes.parsingRuleEdit(rules[i].id)), - onToggle: (enabled) => ref - .read(rulesControllerProvider.notifier) - .setEnabled(rules[i].id, enabled: enabled), - ), + : ListView( + children: [ + for (final header in sortedHeaders) ...[ + Padding( + padding: + const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + header, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: p.ink2, + ), + ), + ), + for (final (i, rule) in groups[header]!.indexed) ...[ + if (i > 0) Container(height: 1, color: p.line), + RuleCard( + rule: rule, + categoryById: categoryById, + accountById: accountById, + onTap: () => context + .push(AppRoutes.parsingRuleEdit(rule.id)), + onToggle: (enabled) => ref + .read(rulesControllerProvider.notifier) + .setEnabled(rule.id, enabled: enabled), + ), + ], + ], + ], ), ), ], @@ -132,6 +166,7 @@ class _RulesListScreenState extends ConsumerState { if (result == null) return; await ref.read(rulesControllerProvider.notifier).create( userId: userId, + packageName: result.packageName, kind: result.kind, matchMode: result.matchMode, pattern: result.pattern, diff --git a/lib/src/features/notification_parsing/presentation/screens/source_apps_screen.dart b/lib/src/features/notification_parsing/presentation/screens/source_apps_screen.dart index 3d1f2bd..fcc75a7 100644 --- a/lib/src/features/notification_parsing/presentation/screens/source_apps_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/source_apps_screen.dart @@ -338,33 +338,66 @@ class _AddedAppTile extends ConsumerWidget { onTap: () => context.push(AppRoutes.parsingAppBindings(app.packageName)), child: Padding( padding: const EdgeInsets.fromLTRB(14, 8, 8, 8), - child: Row( + child: Column( children: [ - Icon(Icons.apps_outlined, size: 20, color: p.ink2), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, - style: TextStyle(fontSize: 14, color: p.ink)), - if (app.displayName != null) - Text(app.packageName, - style: TextStyle(fontSize: 11, color: p.ink2)), - ], + Row( + children: [ + Icon(Icons.apps_outlined, size: 20, color: p.ink2), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: TextStyle(fontSize: 14, color: p.ink)), + if (app.displayName != null) + Text(app.packageName, + style: TextStyle(fontSize: 11, color: p.ink2)), + ], + ), + ), + Switch( + value: app.enabled, + activeThumbColor: p.accent, + onChanged: (v) => ctrl.setEnabled(app.id, enabled: v), + ), + IconButton( + icon: Icon(Icons.delete_outline, size: 20, color: p.ink2), + tooltip: l10n.commonDelete, + onPressed: () => ctrl.delete(app.id), + ), + Icon(Icons.chevron_right, size: 18, color: p.ink2), + ], + ), + // «Мерчант — само приложение» (Ozon): уведомления не называют + // продавца — категория выбирается вручную, правила не предлагаются. + Padding( + padding: const EdgeInsets.only(left: 32), + child: Tooltip( + message: l10n.sourceAppsSelfMerchantHint, + child: Row( + children: [ + Icon(Icons.storefront_outlined, size: 16, color: p.ink2), + const SizedBox(width: 8), + Expanded( + child: Text(l10n.sourceAppsSelfMerchantLabel, + style: TextStyle(fontSize: 12, color: p.ink2)), + ), + SizedBox( + height: 32, + width: 40, + child: Checkbox( + value: app.selfMerchant, + activeColor: p.accent, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (v) => + ctrl.setSelfMerchant(app.id, value: v ?? false), + ), + ), + ], + ), ), ), - Switch( - value: app.enabled, - activeThumbColor: p.accent, - onChanged: (v) => ctrl.setEnabled(app.id, enabled: v), - ), - IconButton( - icon: Icon(Icons.delete_outline, size: 20, color: p.ink2), - tooltip: l10n.commonDelete, - onPressed: () => ctrl.delete(app.id), - ), - Icon(Icons.chevron_right, size: 18, color: p.ink2), ], ), ), diff --git a/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart b/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart index f722326..4f39974 100644 --- a/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart +++ b/lib/src/features/notification_parsing/presentation/widgets/inbox_card.dart @@ -6,8 +6,11 @@ import '../../../../app/l10n/l10n.dart'; import '../../../../app/router/app_routes.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../../core/database/converters/enum_converters.dart'; +import '../../../accounts/application/accounts_controller.dart'; import '../../../categories/domain/entities/category.dart'; import '../../../home/presentation/widgets/money_text.dart'; +import '../../../transactions/presentation/screens/transaction_form_screen.dart'; +import '../../../transactions/presentation/widgets/account_picker_sheet.dart'; import '../../../transactions/presentation/widgets/category_picker_sheet.dart'; import '../../application/inbox_controller.dart'; import '../../data/parser/draft_codec.dart'; @@ -18,7 +21,7 @@ import 'confidence_badge.dart'; import 'gate_check_labels.dart'; /// Карточка Inbox (§12.2): мерчант, сумма, подсветка слабых полей и три -/// действия — «Создать правило», «Подтвердить разово», «Игнорировать». +/// действия — «Создать правило», «Подтвердить», «Игнорировать». class InboxCard extends ConsumerWidget { const InboxCard({ super.key, @@ -50,10 +53,17 @@ class InboxCard extends ConsumerWidget { ? _FailedBody(message: message) : bundle == null ? _UnrecognizedBody(message: message) + // pairedRawMessageId → склеенная пара «Перевод между счетами». + : bundle.pairedRawMessageId != null + ? _TransferPairBody( + message: message, + userId: userId, + bundle: bundle, + ) // suggestion != null → знакомый мерчант: предлагаем правило. - // suggestion == null → нет мерчанта (только сумма) ИЛИ мерчант - // помечен как mixed → «подтвердить разово» без создания правила. - : bundle.suggestion != null + // suggestion == null → нет мерчанта (только сумма) ИЛИ источник + // с selfMerchant → «Подтвердить» без создания правила. + : bundle.suggestion != null ? _RecognizedBody( message: message, userId: userId, @@ -169,10 +179,11 @@ class _RecognizedBody extends ConsumerWidget { ), ], const SizedBox(height: 12), - _CreateRuleButton( + _SplitActionButton( label: categoryName != null ? l10n.inboxCreateRule(merchant, categoryName) : l10n.inboxCreateRuleNoCategory(merchant), + editTooltip: l10n.inboxEditRuleTooltip, onTap: () => _createRule( context, ref, @@ -194,16 +205,24 @@ class _RecognizedBody extends ConsumerWidget { Expanded( child: _SecondaryButton( icon: Icons.check, - label: l10n.inboxConfirmOnce, - onTap: accountId == null - ? null - : () => ref.read(inboxControllerProvider.notifier).confirmOnce( + label: l10n.inboxConfirm, + onTap: () async { + final acc = await _resolveAccount(context, + userId: userId, accountId: accountId); + if (acc == null || !context.mounted) return; + await _runReporting( + context, + () => ref + .read(inboxControllerProvider.notifier) + .confirmOnce( userId: userId, message: message, draft: draft, - accountId: accountId, + accountId: acc, categoryId: categoryId, ), + ); + }, ), ), const SizedBox(width: 8), @@ -217,29 +236,6 @@ class _RecognizedBody extends ConsumerWidget { ), ], ), - // «Категории различаются» — мерчант вроде Ozon, у которого категория - // меняется от покупки к покупке: правило для него бессмысленно. - Align( - alignment: Alignment.centerLeft, - child: TextButton.icon( - onPressed: () => - ref.read(inboxControllerProvider.notifier).markMerchantMixed( - userId: userId, - message: message, - bundle: bundle, - merchantCanonical: merchant, - ), - style: TextButton.styleFrom( - foregroundColor: p.ink2, - padding: const EdgeInsets.symmetric(vertical: 4), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - icon: const Icon(Icons.shuffle, size: 14), - label: Text(l10n.inboxMarkMixed, - style: const TextStyle(fontSize: 12)), - ), - ), ], ); } @@ -251,22 +247,27 @@ class _RecognizedBody extends ConsumerWidget { required String? categoryId, required String merchant, }) async { - if (accountId == null) return; final draft = bundle.draft; if (categoryId == null) { await _openEditor(context, ref, accountId: accountId, categoryId: categoryId, merchant: merchant); return; } - await ref.read(inboxControllerProvider.notifier).createRule( - userId: userId, - message: message, - draft: draft, - accountId: accountId, - categoryId: categoryId, - merchantCanonical: merchant, - pattern: draft.merchantRaw ?? merchant, - ); + final acc = + await _resolveAccount(context, userId: userId, accountId: accountId); + if (acc == null || !context.mounted) return; + await _runReporting( + context, + () => ref.read(inboxControllerProvider.notifier).createRule( + userId: userId, + message: message, + draft: draft, + accountId: acc, + categoryId: categoryId, + merchantCanonical: merchant, + pattern: draft.merchantRaw ?? merchant, + ), + ); } Future _openEditor( @@ -280,33 +281,65 @@ class _RecognizedBody extends ConsumerWidget { final result = await context.push( AppRoutes.parsingRuleNew, extra: RuleEditorPrefill( + packageName: message.packageName, pattern: draft.merchantRaw ?? merchant, merchantCanonical: merchant, categoryId: categoryId, accountId: accountId, ), ); - if (result == null) return; - final acc = result.accountId ?? accountId; - if (acc == null) return; - await ref.read(inboxControllerProvider.notifier).createRule( - userId: userId, - message: message, - draft: draft, - accountId: acc, - categoryId: result.categoryId, - merchantCanonical: - result.merchantCanonical.isEmpty ? merchant : result.merchantCanonical, - pattern: result.pattern, - matchMode: result.matchMode, - ); + if (result == null || !context.mounted) return; + final acc = await _resolveAccount(context, + userId: userId, accountId: result.accountId ?? accountId); + if (acc == null || !context.mounted) return; + await _runReporting( + context, + () => ref.read(inboxControllerProvider.notifier).createRule( + userId: userId, + message: message, + draft: draft, + accountId: acc, + categoryId: result.categoryId, + merchantCanonical: result.merchantCanonical.isEmpty + ? merchant + : result.merchantCanonical, + pattern: result.pattern, + matchMode: result.matchMode, + ), + ); } } +/// Выполняет действие контроллера и показывает SnackBar при ошибке — +/// InboxController делает rethrow, и без обработки тап выглядит как no-op. +Future _runReporting( + BuildContext context, + Future Function() action, +) async { + try { + await action(); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.toString()))); + } +} + +/// Счёт для подтверждения: переданный [accountId] (draft/дефолт), иначе — +/// пикер счёта. `null` из пикера = пользователь отменил. +Future _resolveAccount( + BuildContext context, { + required String userId, + String? accountId, +}) async { + if (accountId != null) return accountId; + return showAccountPicker(context, userId: userId); +} + /// Карточка без предложения правила (§9.1): уведомление без мерчанта (только -/// сумма) или мерчант помечен как mixed. Создавать merchant→category-правило -/// тут нельзя/бессмысленно, поэтому единственное действие — выбрать категорию -/// и «подтвердить разово». +/// сумма) или источник с selfMerchant (Ozon). Создавать merchant→category +/// правило тут нельзя/бессмысленно: пользователь выбирает категорию +/// (обязательно) и подтверждает — либо уходит в полную форму карандашом. class _ConfirmOnceBody extends ConsumerStatefulWidget { const _ConfirmOnceBody({ required this.message, @@ -358,10 +391,22 @@ class _ConfirmOnceBodyState extends ConsumerState<_ConfirmOnceBody> { draft.merchantRaw ?? message.title ?? '—'; - final accountId = draft.accountId ?? widget.defaultAccountId; final categoryName = _categoryId != null ? widget.categoryById[_categoryId]?.name : null; + // Чипы частых категорий этого приложения — быстрый выбор без пикера. + final topCategoryIds = ref + .watch(topConfirmCategoriesProvider( + widget.userId, message.packageName, draft.type)) + .value ?? + const []; + final chipCategories = topCategoryIds + .map((id) => widget.categoryById[id]) + .whereType() + .where((c) => !c.archived) + .take(4) + .toList(); + final signed = draft.type == TransactionType.expense ? -draft.amount : draft.amount; final amountColor = @@ -410,7 +455,22 @@ class _ConfirmOnceBodyState extends ConsumerState<_ConfirmOnceBody> { style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3)), ], const SizedBox(height: 12), - // Строка выбора категории (необязательно — можно подтвердить и без неё). + if (chipCategories.isNotEmpty) ...[ + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final c in chipCategories) + _CategoryChip( + label: c.name, + selected: _categoryId == c.id, + onTap: () => setState(() => _categoryId = c.id), + ), + ], + ), + const SizedBox(height: 8), + ], + // Строка выбора категории — категория обязательна для подтверждения. InkWell( borderRadius: BorderRadius.circular(10), onTap: () async { @@ -446,31 +506,21 @@ class _ConfirmOnceBodyState extends ConsumerState<_ConfirmOnceBody> { ), ), ), + if (_categoryId == null) ...[ + const SizedBox(height: 6), + Text(l10n.inboxCategoryRequiredHint, + style: TextStyle(fontSize: 12, color: p.ink2)), + ], const SizedBox(height: 10), Row( children: [ Expanded( flex: 2, - child: FilledButton.icon( - onPressed: accountId == null - ? null - : () => ref - .read(inboxControllerProvider.notifier) - .confirmOnce( - userId: widget.userId, - message: message, - draft: draft, - accountId: accountId, - categoryId: _categoryId, - ), - style: FilledButton.styleFrom( - backgroundColor: p.accent, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - icon: const Icon(Icons.check, size: 16), - label: Text(l10n.inboxConfirm, - overflow: TextOverflow.ellipsis), + child: _SplitActionButton( + label: l10n.inboxConfirm, + editTooltip: l10n.inboxEditRuleTooltip, + onTap: _categoryId == null ? null : _confirm, + onEdit: _openFullForm, ), ), const SizedBox(width: 8), @@ -487,6 +537,323 @@ class _ConfirmOnceBodyState extends ConsumerState<_ConfirmOnceBody> { ], ); } + + /// Галочка: мгновенная транзакция с выбранной категорией. Без счёта — + /// открывает пикер счёта (отмена пикера = ничего не делаем). + Future _confirm() async { + final message = widget.message; + final draft = widget.bundle.draft; + final acc = await _resolveAccount( + context, + userId: widget.userId, + accountId: draft.accountId ?? widget.defaultAccountId, + ); + if (acc == null || !mounted) return; + await _runReporting( + context, + () => ref.read(inboxControllerProvider.notifier).confirmOnce( + userId: widget.userId, + message: message, + draft: draft, + accountId: acc, + categoryId: _categoryId, + ), + ); + } + + /// Карандаш: полная форма транзакции с префиллом — там можно вписать + /// реального мерчанта. После сохранения сообщение линкуется (→ applied). + Future _openFullForm() async { + final message = widget.message; + final draft = widget.bundle.draft; + final txId = await context.push( + AppRoutes.transactionNew, + extra: TransactionFormPrefill( + type: draft.type, + amountMinor: draft.amount, + date: draft.dateTime ?? message.receivedAt, + accountId: draft.accountId ?? widget.defaultAccountId, + categoryId: _categoryId, + merchant: draft.merchantCanonical ?? draft.merchantRaw ?? message.title, + rawMessageId: message.id, + ), + ); + if (txId == null || txId.isEmpty) return; + await ref + .read(inboxControllerProvider.notifier) + .markApplied(message, txId); + } +} + +/// Склеенная пара «Перевод между счетами»: сумма, счёт-источник → счёт +/// зачисления (пикеры при неразрешённых счетах), оба приложения-источника. +/// Действия: «Подтвердить» (одна transfer-транзакция), «Расклеить» +/// (две одиночные карточки + blocklist), «Игнорировать» (обе половинки). +class _TransferPairBody extends ConsumerStatefulWidget { + const _TransferPairBody({ + required this.message, + required this.userId, + required this.bundle, + }); + + final RawMessage message; + final String userId; + final DraftBundle bundle; + + @override + ConsumerState<_TransferPairBody> createState() => _TransferPairBodyState(); +} + +class _TransferPairBodyState extends ConsumerState<_TransferPairBody> { + String? _fromId; + String? _toId; + + @override + void initState() { + super.initState(); + _fromId = widget.bundle.draft.accountId; + _toId = widget.bundle.draft.transferToAccountId; + } + + @override + Widget build(BuildContext context) { + final p = context.palette; + final l10n = context.l10n; + final draft = widget.bundle.draft; + final secondaryId = widget.bundle.pairedRawMessageId!; + + final accounts = + ref.watch(accountsStreamProvider(widget.userId)).value ?? const []; + final accountName = {for (final a in accounts) a.id: a.name}; + + final primaryApp = ref + .watch(sourceAppLabelProvider(widget.userId, widget.message.packageName)) + .value; + final secondary = ref.watch(pairedRawMessageProvider(secondaryId)).value; + final secondaryApp = secondary == null + ? null + : ref + .watch(sourceAppLabelProvider(widget.userId, secondary.packageName)) + .value; + final appsLine = [primaryApp, secondaryApp].nonNulls.join(' → '); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon(Icons.swap_horiz, size: 18, color: p.accent), + const SizedBox(width: 6), + Expanded( + child: Text( + l10n.inboxTransferPairTitle, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: p.ink, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + MoneyText(draft.amount, + color: p.ink, fontSize: 15, fontWeight: FontWeight.w700), + ], + ), + if (appsLine.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(appsLine, + style: TextStyle(fontSize: 13, color: p.ink2), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ], + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _AccountSlot( + label: l10n.inboxTransferFrom, + name: _fromId != null ? accountName[_fromId] : null, + onTap: () => _pickAccount(isFrom: true), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Icon(Icons.arrow_forward, size: 16, color: p.ink2), + ), + Expanded( + child: _AccountSlot( + label: l10n.inboxTransferTo, + name: _toId != null ? accountName[_toId] : null, + onTap: () => _pickAccount(isFrom: false), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + widget.message.body, + style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 12), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: p.accent), + onPressed: _confirm, + child: Text(l10n.inboxConfirm), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _SecondaryButton( + icon: Icons.link_off, + label: l10n.inboxUnpair, + onTap: () => _runReporting( + context, + () => ref.read(inboxControllerProvider.notifier).unpair( + userId: widget.userId, + message: widget.message, + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _SecondaryButton( + icon: Icons.close, + label: l10n.inboxIgnore, + onTap: () => ref + .read(inboxControllerProvider.notifier) + .ignore(widget.message), + ), + ), + ], + ), + ], + ); + } + + Future _pickAccount({required bool isFrom}) async { + final id = await showAccountPicker(context, userId: widget.userId); + if (id == null || !mounted) return; + setState(() => isFrom ? _fromId = id : _toId = id); + } + + /// «Подтвердить»: недостающие счета запрашиваются пикером по тапу + /// (кнопку не дизейблим), затем создаётся одна transfer-транзакция. + Future _confirm() async { + var from = _fromId; + if (from == null) { + from = await showAccountPicker(context, userId: widget.userId); + if (from == null || !mounted) return; + setState(() => _fromId = from); + } + var to = _toId; + if (to == null) { + to = await showAccountPicker(context, userId: widget.userId); + if (to == null || !mounted) return; + setState(() => _toId = to); + } + if (from == to) return; // перевод на тот же счёт не имеет смысла + await _runReporting( + context, + () => ref.read(inboxControllerProvider.notifier).confirmPair( + userId: widget.userId, + message: widget.message, + draft: widget.bundle.draft, + secondaryId: widget.bundle.pairedRawMessageId!, + fromAccountId: from!, + toAccountId: to!, + ), + ); + } +} + +/// Слот счёта в merged-карточке: метка + имя счёта (или «выбрать»). +class _AccountSlot extends StatelessWidget { + const _AccountSlot({ + required this.label, + required this.name, + required this.onTap, + }); + + final String label; + final String? name; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: p.paper, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: p.line), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(fontSize: 11, color: p.ink2)), + const SizedBox(height: 2), + Text( + name ?? context.l10n.inboxTransferPickAccount, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: name != null ? p.ink : p.accent, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } +} + +/// Пилюля быстрого выбора категории (частые категории приложения). +class _CategoryChip extends StatelessWidget { + const _CategoryChip({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return InkWell( + borderRadius: BorderRadius.circular(20), + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: selected ? p.accentSoft : p.paper, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: selected ? p.accent : p.line), + ), + child: Text( + label, + style: TextStyle( + fontSize: 13, + color: selected ? p.accent : p.ink, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + ), + ), + ), + ); + } } class _UnrecognizedBody extends ConsumerWidget { @@ -599,21 +966,26 @@ class _FailedBody extends ConsumerWidget { } } -class _CreateRuleButton extends StatelessWidget { - const _CreateRuleButton({ +/// Акцентная сплит-кнопка «действие | ✎»: основной сегмент выполняет действие +/// ([onTap] == null — сегмент неактивен и приглушён), карандаш всегда активен +/// и открывает редактор/форму. Используется для «Создать правило» и +/// «Подтвердить». +class _SplitActionButton extends StatelessWidget { + const _SplitActionButton({ required this.label, required this.onTap, required this.onEdit, + required this.editTooltip, }); final String label; - final VoidCallback onTap; + final VoidCallback? onTap; final VoidCallback onEdit; + final String editTooltip; @override Widget build(BuildContext context) { final p = context.palette; - final l10n = context.l10n; return Material( color: p.accent, borderRadius: BorderRadius.circular(12), @@ -623,17 +995,20 @@ class _CreateRuleButton extends StatelessWidget { child: InkWell( borderRadius: const BorderRadius.horizontal(left: Radius.circular(12)), onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - child: Text( - label, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Colors.white, + child: Opacity( + opacity: onTap == null ? 0.55 : 1, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), ), ), @@ -643,7 +1018,7 @@ class _CreateRuleButton extends StatelessWidget { borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)), onTap: onEdit, child: Tooltip( - message: l10n.inboxEditRuleTooltip, + message: editTooltip, child: const Padding( padding: EdgeInsets.symmetric(horizontal: 14, vertical: 12), child: Icon(Icons.edit_outlined, size: 18, color: Colors.white), diff --git a/lib/src/features/notification_parsing/presentation/widgets/rule_card.dart b/lib/src/features/notification_parsing/presentation/widgets/rule_card.dart index e945a2e..fd2c9f2 100644 --- a/lib/src/features/notification_parsing/presentation/widgets/rule_card.dart +++ b/lib/src/features/notification_parsing/presentation/widgets/rule_card.dart @@ -33,7 +33,6 @@ class RuleCard extends StatelessWidget { ParseRuleKind.merchantToCategory => Icons.storefront_outlined, ParseRuleKind.senderToAccount => Icons.credit_card_outlined, ParseRuleKind.ignore => Icons.block_outlined, - ParseRuleKind.mixedMerchant => Icons.shuffle, }; final categoryName = diff --git a/lib/src/features/transactions/presentation/screens/transaction_form_screen.dart b/lib/src/features/transactions/presentation/screens/transaction_form_screen.dart index b4509bc..e7749a5 100644 --- a/lib/src/features/transactions/presentation/screens/transaction_form_screen.dart +++ b/lib/src/features/transactions/presentation/screens/transaction_form_screen.dart @@ -27,12 +27,38 @@ import '../widgets/habit_selectors.dart'; import '../widgets/transfer_account_row.dart'; import '../widgets/type_segmented.dart'; +/// Предзаполнение формы при создании транзакции из Inbox (карандаш на +/// карточке): поля черновика из распознанного уведомления + [rawMessageId] +/// для линковки сообщения к сохранённой транзакции. +class TransactionFormPrefill { + const TransactionFormPrefill({ + required this.type, + required this.amountMinor, + required this.date, + this.accountId, + this.categoryId, + this.merchant, + this.rawMessageId, + }); + + final TransactionType type; + final int amountMinor; + final DateTime date; + final String? accountId; + final String? categoryId; + final String? merchant; + final String? rawMessageId; +} + class TransactionFormScreen extends ConsumerWidget { - const TransactionFormScreen({super.key, this.txId}); + const TransactionFormScreen({super.key, this.txId, this.prefill}); /// `null` → создание новой; иначе — редактирование. final String? txId; + /// Предзаполнение при создании (только когда [txId] == null). + final TransactionFormPrefill? prefill; + @override Widget build(BuildContext context, WidgetRef ref) { final p = context.palette; @@ -46,6 +72,9 @@ class TransactionFormScreen extends ConsumerWidget { data: (user) { if (user == null) return const SizedBox.shrink(); if (txId == null) { + if (prefill != null) { + return _PrefillHydrator(userId: user.id, prefill: prefill!); + } return _FormBody(userId: user.id, txId: null); } // Edit-режим: ждём загрузку транзакции и гидратируем черновик. @@ -105,11 +134,57 @@ class _EditHydratorState extends ConsumerState<_EditHydrator> { } } +/// Заполняет черновик новой транзакции префиллом из Inbox один раз при первом +/// построении. Post-frame гидрация гарантированно раньше подстановки +/// умолчательного счёта в [_FormBody] — та заполняет счёт, только если он +/// ещё null. +class _PrefillHydrator extends ConsumerStatefulWidget { + const _PrefillHydrator({required this.userId, required this.prefill}); + final String userId; + final TransactionFormPrefill prefill; + + @override + ConsumerState<_PrefillHydrator> createState() => _PrefillHydratorState(); +} + +class _PrefillHydratorState extends ConsumerState<_PrefillHydrator> { + bool _hydrated = false; + + @override + Widget build(BuildContext context) { + if (!_hydrated) { + _hydrated = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final pf = widget.prefill; + ref + .read(transactionDraftControllerProvider(null).notifier) + .hydrate(TransactionDraft( + type: pf.type, + amountMinor: pf.amountMinor, + date: pf.date, + accountId: pf.accountId, + categoryId: pf.categoryId, + merchant: pf.merchant, + )); + }); + } + return _FormBody( + userId: widget.userId, + txId: null, + rawMessageId: widget.prefill.rawMessageId, + ); + } +} + class _FormBody extends ConsumerStatefulWidget { - const _FormBody({required this.userId, required this.txId}); + const _FormBody({required this.userId, required this.txId, this.rawMessageId}); final String userId; final String? txId; + /// Источник-уведомление (карандаш на карточке Inbox); null для FAB/edit. + final String? rawMessageId; + @override ConsumerState<_FormBody> createState() => _FormBodyState(); } @@ -195,7 +270,7 @@ class _FormBodyState extends ConsumerState<_FormBody> { final controller = ref.read(transactionsControllerProvider.notifier); try { if (widget.txId == null) { - await controller.createTransaction( + final tx = await controller.createTransaction( userId: widget.userId, accountId: draft.accountId!, categoryId: draft.type == TransactionType.transfer ? null : draft.categoryId, @@ -211,7 +286,12 @@ class _FormBodyState extends ConsumerState<_FormBody> { draft.type == TransactionType.expense ? draft.obligation : null, impulse: draft.type == TransactionType.expense ? draft.impulse : null, + rawMessageId: widget.rawMessageId, ); + if (!mounted) return; + // id — для вызывающего (Inbox линкует сообщение к транзакции). + Navigator.of(context).pop(tx.id); + return; } else { final existing = await ref .read(transactionRepositoryProvider) diff --git a/lib/src/features/user/application/user_seeder.dart b/lib/src/features/user/application/user_seeder.dart index 8a29d93..be9bead 100644 --- a/lib/src/features/user/application/user_seeder.dart +++ b/lib/src/features/user/application/user_seeder.dart @@ -21,7 +21,8 @@ UserSeeder userSeeder(Ref ref) => UserSeeder( ); /// Засевает базовый набор данных при создании нового пользователя: -/// несколько счетов и категорий. Демо-транзакции автоматически НЕ создаются — +/// только категории. Первый счёт пользователь создаёт сам на втором шаге +/// онбординга. Демо-счета и демо-транзакции автоматически НЕ создаются — /// их можно добавить вручную через [seedDemoTransactionsForUser] (кнопка в профиле). class UserSeeder { UserSeeder({ @@ -35,7 +36,8 @@ class UserSeeder { final TransactionRepository txRepo; Future seedForNewUser(String userId) async { - await _seedAccounts(userId); + // Счета больше не засеваются автоматически: первый счёт создаётся вручную + // на втором шаге онбординга и там же помечается умолчательным. await _seedCategories(userId); } diff --git a/lib/src/features/user/presentation/screens/onboarding_screen.dart b/lib/src/features/user/presentation/screens/onboarding_screen.dart index f27edfd..3c723e9 100644 --- a/lib/src/features/user/presentation/screens/onboarding_screen.dart +++ b/lib/src/features/user/presentation/screens/onboarding_screen.dart @@ -1,12 +1,19 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../app/l10n/l10n.dart'; import '../../../../app/theme/app_colors.dart'; +import '../../../../core/database/converters/enum_converters.dart'; import '../../../../core/logging/app_logger.dart'; +import '../../../accounts/application/accounts_controller.dart'; +import '../../../accounts/presentation/widgets/currency_picker_sheet.dart'; +import '../../../categories/presentation/widgets/category_color_palette.dart'; import '../../application/active_user_controller.dart'; import '../../application/users_controller.dart'; +enum _Step { name, account } + class OnboardingScreen extends ConsumerStatefulWidget { const OnboardingScreen({super.key}); @@ -16,123 +23,420 @@ class OnboardingScreen extends ConsumerStatefulWidget { class _OnboardingScreenState extends ConsumerState { final _nameCtrl = TextEditingController(); + final _accountNameCtrl = TextEditingController(); + final _balanceCtrl = TextEditingController(); + + _Step _step = _Step.name; + AccountType _type = AccountType.cash; + String _currency = 'RUB'; + final int _colorValue = randomCategoryColor(); bool _submitting = false; @override void dispose() { _nameCtrl.dispose(); + _accountNameCtrl.dispose(); + _balanceCtrl.dispose(); super.dispose(); } + void _goToAccountStep() { + if (_nameCtrl.text.trim().isEmpty) return; + setState(() => _step = _Step.account); + } + + int _parseBalance() { + final text = _balanceCtrl.text.trim().replaceAll(',', '.'); + final val = double.tryParse(text) ?? 0.0; + return (val * 100).round(); + } + + /// Финальный сабмит второго шага: создаёт пользователя (с категориями), + /// первый счёт (помеченный умолчательным) и делает пользователя активным. + /// Только на этом шаге происходят записи в БД — при отмене онбординга + /// на первом шаге ничего не создаётся. Future _submit() async { final name = _nameCtrl.text.trim(); - if (name.isEmpty || _submitting) return; + final accountName = _accountNameCtrl.text.trim(); + if (name.isEmpty || accountName.isEmpty || _submitting) return; setState(() => _submitting = true); try { final user = await ref.read(usersControllerProvider.notifier).createUser(name); + final accountsCtrl = ref.read(accountsControllerProvider.notifier); + final account = await accountsCtrl.createAccount( + userId: user.id, + name: accountName, + type: _type, + currency: _currency, + initialBalance: _parseBalance(), + colorValue: _colorValue, + ); + // Первый счёт — умолчательный: без него «Подтвердить» на карточках Inbox + // требует ручного выбора счёта у каждого сообщения. + await accountsCtrl.setDefaultAccount(account.id, user.id); await ref .read(activeUserControllerProvider.notifier) .setActiveUser(user); // Redirect в роутере подхватит изменение activeUser и переведёт на /home. } catch (e, st) { AppLogger.error( - 'Failed to create user / set active user', + 'Failed to create user / first account', error: e, stackTrace: st, tag: 'onboarding', ); if (mounted) { + setState(() => _submitting = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('$e')), ); } - } finally { - if (mounted) setState(() => _submitting = false); } } @override Widget build(BuildContext context) { final p = context.palette; - final l10n = context.l10n; return Scaffold( backgroundColor: p.paper, body: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Spacer(), - Text( - l10n.onboardingTitle, - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.w700, - color: p.ink, - letterSpacing: -0.4, - ), - ), - const SizedBox(height: 8), - Text( - l10n.onboardingSubtitle, - style: TextStyle(fontSize: 14, color: p.ink2), - ), - const SizedBox(height: 24), - TextField( - controller: _nameCtrl, - autofocus: true, - textInputAction: TextInputAction.done, - onSubmitted: (_) => _submit(), - style: TextStyle(fontSize: 16, color: p.ink), - decoration: InputDecoration( - labelText: l10n.onboardingNameLabel, - hintText: l10n.onboardingNameHint, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: p.line), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: p.line), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: p.accent), + child: _step == _Step.name ? _buildNameStep() : _buildAccountStep(), + ), + ), + ); + } + + Widget _buildNameStep() { + final p = context.palette; + final l10n = context.l10n; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Spacer(), + Text( + l10n.onboardingTitle, + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w700, + color: p.ink, + letterSpacing: -0.4, + ), + ), + const SizedBox(height: 8), + Text( + l10n.onboardingSubtitle, + style: TextStyle(fontSize: 14, color: p.ink2), + ), + const SizedBox(height: 24), + TextField( + controller: _nameCtrl, + autofocus: true, + textInputAction: TextInputAction.next, + onChanged: (_) => setState(() {}), + onSubmitted: (_) => _goToAccountStep(), + style: TextStyle(fontSize: 16, color: p.ink), + decoration: _inputDecoration( + label: l10n.onboardingNameLabel, + hint: l10n.onboardingNameHint, + ), + ), + const Spacer(), + _PrimaryButton( + label: l10n.onboardingContinue, + loading: false, + onPressed: + _nameCtrl.text.trim().isEmpty ? null : _goToAccountStep, + ), + ], + ); + } + + Widget _buildAccountStep() { + final p = context.palette; + final l10n = context.l10n; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + alignment: Alignment.centerLeft, + child: IconButton( + onPressed: _submitting + ? null + : () => setState(() => _step = _Step.name), + padding: EdgeInsets.zero, + icon: Icon(Icons.arrow_back, color: p.ink), + ), + ), + const SizedBox(height: 8), + Text( + l10n.onboardingAccountTitle, + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w700, + color: p.ink, + letterSpacing: -0.4, + ), + ), + const SizedBox(height: 8), + Text( + l10n.onboardingAccountSubtitle, + style: TextStyle(fontSize: 14, color: p.ink2), + ), + const SizedBox(height: 24), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _accountNameCtrl, + autofocus: true, + maxLength: 50, + textCapitalization: TextCapitalization.sentences, + onChanged: (_) => setState(() {}), + style: TextStyle(fontSize: 16, color: p.ink), + decoration: _inputDecoration( + label: l10n.accountNameLabel, + hint: l10n.accountNameHint, + counterText: '', ), ), - ), - const Spacer(), - SizedBox( - width: double.infinity, - height: 52, - child: FilledButton( - onPressed: _submitting ? null : _submit, - style: FilledButton.styleFrom( - backgroundColor: p.ink, - foregroundColor: p.paper, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: _submitting - ? SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(p.paper), - ), - ) - : Text( - l10n.onboardingContinue, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, + const SizedBox(height: 16), + Text( + l10n.accountTypeLabel, + style: TextStyle(fontSize: 12, color: p.ink2), + ), + const SizedBox(height: 8), + _TypeSelector( + value: _type, + onChanged: (t) => setState(() => _type = t), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _FieldTile( + label: l10n.accountCurrencyLabel, + child: InkWell( + onTap: () async { + final picked = await showCurrencyPicker( + context, + current: _currency, + ); + if (picked != null) { + setState(() => _currency = picked); + } + }, + child: Row( + children: [ + Text( + '${symbolForCurrency(_currency)} $_currency', + style: TextStyle(fontSize: 16, color: p.ink), + ), + const Spacer(), + Icon(Icons.chevron_right, + size: 18, color: p.ink2), + ], ), ), + ), + ), + ], + ), + const SizedBox(height: 12), + _FieldTile( + label: l10n.accountInitialBalanceLabel, + child: TextField( + controller: _balanceCtrl, + keyboardType: const TextInputType.numberWithOptions( + decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[\d.,]')), + ], + style: TextStyle(fontSize: 16, color: p.ink), + decoration: InputDecoration( + isCollapsed: true, + contentPadding: + const EdgeInsets.symmetric(vertical: 8), + border: InputBorder.none, + hintText: '0.00', + hintStyle: TextStyle(fontSize: 16, color: p.ink2), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + _PrimaryButton( + label: l10n.onboardingContinue, + loading: _submitting, + onPressed: (_submitting || _accountNameCtrl.text.trim().isEmpty) + ? null + : _submit, + ), + ], + ); + } + + InputDecoration _inputDecoration({ + required String label, + required String hint, + String? counterText, + }) { + final p = context.palette; + return InputDecoration( + labelText: label, + hintText: hint, + counterText: counterText, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: p.line), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: p.line), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: p.accent), + ), + ); + } +} + +/// Плитка поля: подпись сверху, произвольное содержимое в рамке. +class _FieldTile extends StatelessWidget { + const _FieldTile({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return Container( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 12), + decoration: BoxDecoration( + color: p.paper2, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: p.line), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(fontSize: 12, color: p.ink2)), + const SizedBox(height: 6), + child, + ], + ), + ); + } +} + +/// Выбор типа счёта: два ряда по две сегмент-кнопки. +class _TypeSelector extends StatelessWidget { + const _TypeSelector({required this.value, required this.onChanged}); + + final AccountType value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final p = context.palette; + final l10n = context.l10n; + final items = <(AccountType, String, IconData)>[ + (AccountType.cash, l10n.accountTypeCash, Icons.payments_outlined), + (AccountType.card, l10n.accountTypeCard, Icons.credit_card_outlined), + (AccountType.bank, l10n.accountTypeBank, Icons.account_balance_outlined), + (AccountType.savings, l10n.accountTypeSavings, Icons.savings_outlined), + ]; + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: p.paper2, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: p.line), + ), + child: Column( + children: [ + Row( + children: [ + for (final (type, label, icon) in items.take(2)) + Expanded( + child: _TypeSegment( + label: label, + icon: icon, + selected: type == value, + onTap: () => onChanged(type), + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + for (final (type, label, icon) in items.skip(2)) + Expanded( + child: _TypeSegment( + label: label, + icon: icon, + selected: type == value, + onTap: () => onChanged(type), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _TypeSegment extends StatelessWidget { + const _TypeSegment({ + required this.label, + required this.icon, + required this.selected, + required this.onTap, + }); + + final String label; + final IconData icon; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return Material( + color: selected ? p.paper : Colors.transparent, + borderRadius: BorderRadius.circular(10), + elevation: selected ? 1 : 0, + shadowColor: Colors.black.withValues(alpha: 0.08), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: SizedBox( + height: 44, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 16, color: selected ? p.accent : p.ink2), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + color: selected ? p.ink : p.ink2, ), ), ], @@ -142,3 +446,51 @@ class _OnboardingScreenState extends ConsumerState { ); } } + +class _PrimaryButton extends StatelessWidget { + const _PrimaryButton({ + required this.label, + required this.loading, + required this.onPressed, + }); + + final String label; + final bool loading; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return SizedBox( + width: double.infinity, + height: 52, + child: FilledButton( + onPressed: onPressed, + style: FilledButton.styleFrom( + backgroundColor: p.ink, + foregroundColor: p.paper, + disabledBackgroundColor: p.ink.withValues(alpha: 0.4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: loading + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(p.paper), + ), + ) + : Text( + label, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 885f924..d2d7438 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: analyzer_buffer - sha256: bf559bc54530827a92cc4d9ee340fc76b4f17f386218c2b9e7cd33ed468a7e4d + sha256: "445b77e2054fa3e8c8a8ef1b5e9e6b23bb8028fffd34b5e60eaef315b7750674" url: "https://pub.dev" source: hosted - version: "0.3.2" + version: "0.3.3" args: dependency: transitive description: @@ -53,34 +53,34 @@ packages: dependency: transitive description: name: build - sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" url: "https://pub.dev" source: hosted - version: "4.0.6" + version: "4.0.7" build_config: dependency: transitive description: name: build_config - sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + sha256: f2c223156a26eea323e6244b85141d76413a80aeee9fe0b380773789fabaf8ae url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.3.1" build_daemon: dependency: transitive description: name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + sha256: fd754058c342243718d5171a95f352cfc9fcf0cba8cfa26df67cb13a5836db78 url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.1.2" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" url: "https://pub.dev" source: hosted - version: "2.15.0" + version: "2.15.1" built_collection: dependency: transitive description: @@ -149,10 +149,10 @@ packages: dependency: transitive description: name: code_assets - sha256: dad6bf6b9f4f378b0a69edbf42584d336efd1a9ce15deb1ba591cbb1b5ff440f + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.1" code_builder: dependency: transitive description: @@ -197,10 +197,10 @@ packages: dependency: transitive description: name: coverage - sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.15.1" crypto: dependency: transitive description: @@ -229,26 +229,26 @@ packages: dependency: transitive description: name: dbus - sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91" + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" url: "https://pub.dev" source: hosted - version: "0.7.13" + version: "0.7.14" drift: dependency: "direct main" description: name: drift - sha256: "8033500116b24398fba0cca0369cc31678cd627c01e41753a61186911cea743e" + sha256: c21b9af62e78f86837638dda6c33506bd9444ca8a32cad2b99c07369cf9e2462 url: "https://pub.dev" source: hosted - version: "2.33.0" + version: "2.34.1" drift_dev: dependency: "direct dev" description: name: drift_dev - sha256: b3dd5b75e30522a91da8abda9f5bb17230cb038097f6d15fa75d42bb563428aa + sha256: "9cfff1576b49725da0d32c040651a41ae195e8c4af8d8da301593e41d7abc2f7" url: "https://pub.dev" source: hosted - version: "2.33.0" + version: "2.34.0" drift_flutter: dependency: "direct main" description: @@ -261,10 +261,10 @@ packages: dependency: transitive description: name: equatable - sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" url: "https://pub.dev" source: hosted - version: "2.0.8" + version: "2.1.0" fake_async: dependency: transitive description: @@ -327,10 +327,10 @@ packages: dependency: "direct main" description: name: flutter_riverpod - sha256: be3aa640f053064e2238f8a308baa5be7270645e8b53b08484fd305bd5c1eb5d + sha256: "9255e1e3ad6e38906a1b4f8287678f95f378744c5b46b1985588543f3f19046e" url: "https://pub.dev" source: hosted - version: "3.3.2-dev.2" + version: "3.3.2" flutter_secure_storage: dependency: "direct main" description: @@ -425,10 +425,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f" + sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a" url: "https://pub.dev" source: hosted - version: "17.2.3" + version: "17.3.0" google_fonts: dependency: "direct main" description: @@ -449,10 +449,10 @@ packages: dependency: transitive description: name: hooks - sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31 + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.2" http: dependency: "direct main" description: @@ -617,10 +617,10 @@ packages: dependency: transitive description: name: native_toolchain_c - sha256: "49c147aa4a5905ec12028e2b7bfe360eace6cb91fe4cf08a3fe76e309592acae" + sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72 url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.19.2" nm: dependency: transitive description: @@ -665,10 +665,10 @@ packages: dependency: transitive description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: dependency: transitive description: @@ -689,18 +689,18 @@ packages: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_windows: dependency: transitive description: @@ -777,10 +777,10 @@ packages: dependency: transitive description: name: riverpod - sha256: da7233961958420e9d80edf4b7a735d5b6b732fe2381d2a12a388562e2042b3f + sha256: "17100416c51db7810c71a7bb2c34d1f881faa0074fd452afb0c4db6f8f126c76" url: "https://pub.dev" source: hosted - version: "3.3.2-dev.2" + version: "3.3.2" riverpod_analyzer_utils: dependency: transitive description: @@ -793,18 +793,18 @@ packages: dependency: "direct main" description: name: riverpod_annotation - sha256: b7fec3dcdef4cc724116b9cad9fd1ca90fb241083cc16e9cf42a85256be7e87e + sha256: "674dbb26e2db3d9253166faf4758c796af14146b8fbcf5e7102bc8a04cd359b8" url: "https://pub.dev" source: hosted - version: "4.0.3-dev.2" + version: "4.0.3" riverpod_generator: dependency: "direct dev" description: name: riverpod_generator - sha256: "2ba125d0f0ece0b7f2a549613803ea1d4a5e1c8b887588223b19ed8671237504" + sha256: "54d790c3fee1ae281c448801bfdbfaa9fd961a9d3998494e0fcd9ee32184d7eb" url: "https://pub.dev" source: hosted - version: "4.0.4-dev.3" + version: "4.0.4" shelf: dependency: transitive description: @@ -894,10 +894,10 @@ packages: dependency: transitive description: name: sqlite3 - sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189" + sha256: "752d9d746052359a2022f588bb979f2e7c4e0f9e4b6a1c3121f7626a1574974b" url: "https://pub.dev" source: hosted - version: "3.3.2" + version: "3.3.4" sqlite3_flutter_libs: dependency: transitive description: @@ -910,10 +910,10 @@ packages: dependency: transitive description: name: sqlparser - sha256: ecdc06d4a7d79dcbc928d99afd2f7f5b0f98a637c46f89be83d911617f759978 + sha256: "40bdddb306a727be9ce510bd2d2b9a6c9db6c586d846ef7b22e3990a2b24f02d" url: "https://pub.dev" source: hosted - version: "0.44.4" + version: "0.44.5" stack_trace: dependency: transitive description: @@ -1078,10 +1078,10 @@ packages: dependency: transitive description: name: xml - sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "6.6.1" yaml: dependency: transitive description: diff --git a/test/core/database/migration_v6_test.dart b/test/core/database/migration_v6_test.dart deleted file mode 100644 index f48d813..0000000 --- a/test/core/database/migration_v6_test.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'dart:io'; - -import 'package:drift/drift.dart' hide isNull, isNotNull; -import 'package:drift/native.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:new_budget/src/core/database/app_database.dart'; -import 'package:new_budget/src/core/database/converters/enum_converters.dart'; - -/// Тест миграции v5 → v6 (habit-tracking). -/// -/// Поднимаем актуальную (v6) схему на файле, затем «откатываем» её до v5, -/// удаляя новые колонки и проставляя user_version = 5. При повторном открытии -/// срабатывает `onUpgrade(5 → 6)`, который должен снова добавить колонки. -void main() { - late File file; - - setUp(() { - final dir = Directory.systemTemp.createTempSync('nb_migration_test'); - file = File('${dir.path}/test.sqlite'); - }); - - tearDown(() { - if (file.existsSync()) file.deleteSync(); - final parent = file.parent; - if (parent.existsSync()) parent.deleteSync(recursive: true); - }); - - test('onUpgrade 5 → 6 добавляет obligation/impulse/habitTrackingEnabled', - () async { - const userId = 'user-1'; - const accountId = 'account-1'; - - // 1. Создаём актуальную схему (v6) и наполняем FK-цепочку. - final dbV6 = AppDatabase.forTesting(NativeDatabase(file)); - await dbV6.usersDao - .insertUser(UsersTableCompanion.insert(id: userId, name: 'Тест')); - await dbV6.accountsDao.insertAccount( - AccountsTableCompanion.insert( - id: accountId, - userId: userId, - name: 'Основной', - ), - ); - - // 2. «Откатываем» схему до v5: убираем колонки v6 И артефакты v7/v8 - // (иначе onUpgrade 5→8 попытается создать их повторно). - await dbV6.customStatement( - 'ALTER TABLE transactions DROP COLUMN obligation'); - await dbV6.customStatement('ALTER TABLE transactions DROP COLUMN impulse'); - await dbV6.customStatement( - 'ALTER TABLE settings DROP COLUMN habit_tracking_enabled'); - await dbV6.customStatement( - 'ALTER TABLE account_bindings DROP COLUMN is_default'); - await dbV6.customStatement('DROP TABLE source_apps'); - await dbV6.customStatement('ALTER TABLE parse_rules DROP COLUMN tx_type'); - await dbV6.customStatement('PRAGMA user_version = 5'); - await dbV6.close(); - - // 3. Повторное открытие запускает onUpgrade(5 → 6). - final dbMigrated = AppDatabase.forTesting(NativeDatabase(file)); - addTearDown(dbMigrated.close); - - // 4. Колонки снова доступны: запись с оценками проходит round-trip. - final repoRow = await dbMigrated.transactionsDao.findById('tx-1'); - expect(repoRow, isNull); - - await dbMigrated.transactionsDao.insertTransaction( - TransactionsTableCompanion.insert( - id: 'tx-1', - userId: userId, - accountId: accountId, - amount: 1000, - date: DateTime(2024, 6, 1), - obligation: const Value(SpendingObligation.required), - impulse: const Value(SpendingImpulse.impulsive), - ), - ); - final tx = await dbMigrated.transactionsDao.findById('tx-1'); - expect(tx!.obligation, SpendingObligation.required); - expect(tx.impulse, SpendingImpulse.impulsive); - - // settings.habitTrackingEnabled присутствует с дефолтом false. - await dbMigrated.settingsDao.upsertSettings( - SettingsTableCompanion.insert(userId: userId), - ); - final settings = await dbMigrated.settingsDao.getSettingsByUser(userId); - expect(settings!.habitTrackingEnabled, isFalse); - }); -} diff --git a/test/core/database/migration_v7_test.dart b/test/core/database/migration_v7_test.dart deleted file mode 100644 index f4d40c9..0000000 --- a/test/core/database/migration_v7_test.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'dart:io'; - -import 'package:drift/drift.dart' hide isNull, isNotNull; -import 'package:drift/native.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:new_budget/src/core/database/app_database.dart'; - -/// Тест миграции v6 → v7 (account_bindings.is_default + таблица source_apps). -/// -/// Поднимаем актуальную (v7) схему, «откатываем» до v6 (убираем is_default и -/// source_apps, ставим user_version = 6). Повторное открытие запускает -/// onUpgrade(6 → 7), который должен восстановить колонку и таблицу. -void main() { - late File file; - - setUp(() { - final dir = Directory.systemTemp.createTempSync('nb_migration_v7_test'); - file = File('${dir.path}/test.sqlite'); - }); - - tearDown(() { - if (file.existsSync()) file.deleteSync(); - final parent = file.parent; - if (parent.existsSync()) parent.deleteSync(recursive: true); - }); - - test('onUpgrade 6 → 7 добавляет is_default и таблицу source_apps', () async { - const userId = 'user-1'; - const accountId = 'account-1'; - - // 1. Актуальная схема (v7) + FK-цепочка. - final dbV7 = AppDatabase.forTesting(NativeDatabase(file)); - await dbV7.usersDao - .insertUser(UsersTableCompanion.insert(id: userId, name: 'Тест')); - await dbV7.accountsDao.insertAccount( - AccountsTableCompanion.insert( - id: accountId, - userId: userId, - name: 'Основной', - ), - ); - - // 2. «Откатываем» до v6 (включая артефакт v8 — parse_rules.tx_type). - await dbV7.customStatement( - 'ALTER TABLE account_bindings DROP COLUMN is_default'); - await dbV7.customStatement('DROP TABLE source_apps'); - await dbV7.customStatement('ALTER TABLE parse_rules DROP COLUMN tx_type'); - await dbV7.customStatement('PRAGMA user_version = 6'); - await dbV7.close(); - - // 3. Повторное открытие → onUpgrade(6 → 7). - final dbMigrated = AppDatabase.forTesting(NativeDatabase(file)); - addTearDown(dbMigrated.close); - - // 4a. is_default доступна с дефолтом false (round-trip привязки). - await dbMigrated.accountBindingsDao.insert( - AccountBindingsTableCompanion.insert( - id: 'b-1', - userId: userId, - accountId: accountId, - packageName: const Value('ru.sberbankmobile'), - ), - ); - final bindings = - await dbMigrated.accountBindingsDao.findByPackageName( - userId, - 'ru.sberbankmobile', - ); - expect(bindings, hasLength(1)); - expect(bindings.first.isDefault, isFalse); - - // 4b. Таблица source_apps существует и принимает строки. - await dbMigrated.sourceAppsDao.insert( - SourceAppsTableCompanion.insert( - id: 's-1', - userId: userId, - packageName: 'ru.sberbankmobile', - ), - ); - final enabled = - await dbMigrated.sourceAppsDao.watchEnabledPackages(userId).first; - expect(enabled, contains('ru.sberbankmobile')); - }); -} diff --git a/test/core/database/migration_v8_test.dart b/test/core/database/migration_v8_test.dart deleted file mode 100644 index 36dc378..0000000 --- a/test/core/database/migration_v8_test.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'dart:io'; - -import 'package:drift/drift.dart' hide isNull, isNotNull; -import 'package:drift/native.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:new_budget/src/core/database/app_database.dart'; -import 'package:new_budget/src/core/database/converters/enum_converters.dart'; -import 'package:new_budget/src/features/notification_parsing/domain/enums.dart'; - -/// Тест миграции v7 → v8 (parse_rules.tx_type для gate-проверки -/// typeMatchesRule). -/// -/// Поднимаем актуальную (v8) схему, «откатываем» до v7 (убираем tx_type, -/// ставим user_version = 7). Повторное открытие запускает onUpgrade(7 → 8), -/// который должен восстановить колонку. -void main() { - late File file; - - setUp(() { - final dir = Directory.systemTemp.createTempSync('nb_migration_v8_test'); - file = File('${dir.path}/test.sqlite'); - }); - - tearDown(() { - if (file.existsSync()) file.deleteSync(); - final parent = file.parent; - if (parent.existsSync()) parent.deleteSync(recursive: true); - }); - - test('onUpgrade 7 → 8 добавляет parse_rules.tx_type', () async { - const userId = 'user-1'; - - // 1. Актуальная схема (v8) + FK-цепочка. - final dbV8 = AppDatabase.forTesting(NativeDatabase(file)); - await dbV8.usersDao - .insertUser(UsersTableCompanion.insert(id: userId, name: 'Тест')); - - // 2. «Откатываем» до v7. - await dbV8.customStatement('ALTER TABLE parse_rules DROP COLUMN tx_type'); - await dbV8.customStatement('PRAGMA user_version = 7'); - await dbV8.close(); - - // 3. Повторное открытие → onUpgrade(7 → 8). - final dbMigrated = AppDatabase.forTesting(NativeDatabase(file)); - addTearDown(dbMigrated.close); - - // 4. Колонка снова доступна: правило с txType проходит round-trip, - // легаси-правило без txType читается с null. - await dbMigrated.parseRulesDao.insert( - ParseRulesTableCompanion.insert( - id: 'r-typed', - userId: userId, - kind: ParseRuleKind.merchantToCategory, - pattern: 'LENTA', - txType: const Value(TransactionType.expense), - ), - ); - await dbMigrated.parseRulesDao.insert( - ParseRulesTableCompanion.insert( - id: 'r-legacy', - userId: userId, - kind: ParseRuleKind.merchantToCategory, - pattern: 'OZON', - ), - ); - - final typed = await dbMigrated.parseRulesDao.findById('r-typed'); - expect(typed!.txType, TransactionType.expense); - - final legacy = await dbMigrated.parseRulesDao.findById('r-legacy'); - expect(legacy!.txType, isNull); - }); -} diff --git a/test/features/analytics/habit_analysis_providers_test.dart b/test/features/analytics/habit_analysis_providers_test.dart index d4e2f8a..387bcbe 100644 --- a/test/features/analytics/habit_analysis_providers_test.dart +++ b/test/features/analytics/habit_analysis_providers_test.dart @@ -84,15 +84,25 @@ void main() { expect(filtered.map((t) => t.id), unorderedEquals(['2', '3'])); }); - test('фильтр по обязательности (мульти) — required + unnecessary', () { + test('фильтр по обязательности — одиночный выбор unnecessary', () { + final c = makeContainer(); + addTearDown(c.dispose); + c + .read(habitObligationFilterProvider.notifier) + .select(SpendingObligation.unnecessary); + + final filtered = c.read(habitFilteredTransactionsProvider(userId)); + expect(filtered.map((t) => t.id), ['2']); + }); + + test('select(null) сбрасывает фильтр обязательности на «Все»', () { final c = makeContainer(); addTearDown(c.dispose); final notifier = c.read(habitObligationFilterProvider.notifier); - notifier.toggle(SpendingObligation.required); - notifier.toggle(SpendingObligation.unnecessary); + notifier.select(SpendingObligation.optional); + notifier.select(null); - final filtered = c.read(habitFilteredTransactionsProvider(userId)); - expect(filtered.map((t) => t.id), unorderedEquals(['1', '2', '4'])); + expect(c.read(habitFilteredTransactionsProvider(userId)).length, 5); }); test('фильтры комбинируются (impulsive AND unnecessary)', () { @@ -103,7 +113,7 @@ void main() { .select(SpendingImpulse.impulsive); c .read(habitObligationFilterProvider.notifier) - .toggle(SpendingObligation.unnecessary); + .select(SpendingObligation.unnecessary); final filtered = c.read(habitFilteredTransactionsProvider(userId)); expect(filtered.map((t) => t.id), ['2']); @@ -114,9 +124,51 @@ void main() { addTearDown(c.dispose); c .read(habitObligationFilterProvider.notifier) - .toggle(SpendingObligation.required); + .select(SpendingObligation.required); final filtered = c.read(habitFilteredTransactionsProvider(userId)); expect(filtered.map((t) => t.id), unorderedEquals(['1', '4'])); }); + + test('выбор импульса сбрасывает обязательность=required (каскад)', () { + final c = makeContainer(); + addTearDown(c.dispose); + c + .read(habitObligationFilterProvider.notifier) + .select(SpendingObligation.required); + c + .read(habitImpulseFilterProvider.notifier) + .select(SpendingImpulse.impulsive); + + expect(c.read(habitObligationFilterProvider), isNull); + final filtered = c.read(habitFilteredTransactionsProvider(userId)); + expect(filtered.map((t) => t.id), unorderedEquals(['2', '3'])); + }); + + test('выбор импульса не трогает обязательность ≠ required', () { + final c = makeContainer(); + addTearDown(c.dispose); + c + .read(habitObligationFilterProvider.notifier) + .select(SpendingObligation.optional); + c + .read(habitImpulseFilterProvider.notifier) + .select(SpendingImpulse.impulsive); + + expect(c.read(habitObligationFilterProvider), SpendingObligation.optional); + }); + + test('habitSumByObligation учитывает активный импульс-фильтр', () { + final c = makeContainer(); + addTearDown(c.dispose); + c + .read(habitImpulseFilterProvider.notifier) + .select(SpendingImpulse.impulsive); + + final map = c.read(habitSumByObligationProvider(userId)); + expect(map[SpendingObligation.unnecessary], 2000); + expect(map[SpendingObligation.optional], 500); + expect(map.containsKey(SpendingObligation.required), isFalse); + expect(map.containsKey(null), isFalse); + }); } diff --git a/test/features/analytics/habit_analysis_screen_test.dart b/test/features/analytics/habit_analysis_screen_test.dart new file mode 100644 index 0000000..da9aba1 --- /dev/null +++ b/test/features/analytics/habit_analysis_screen_test.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:new_budget/l10n/app_localizations.dart'; +import 'package:new_budget/src/app/theme/app_theme.dart'; +import 'package:new_budget/src/core/database/converters/enum_converters.dart'; +import 'package:new_budget/src/features/accounts/application/accounts_controller.dart'; +import 'package:new_budget/src/features/accounts/domain/entities/account.dart'; +import 'package:new_budget/src/features/analytics/application/habit_analysis_providers.dart'; +import 'package:new_budget/src/features/analytics/presentation/screens/habit_analysis_screen.dart'; +import 'package:new_budget/src/features/categories/application/categories_controller.dart'; +import 'package:new_budget/src/features/categories/domain/entities/category.dart'; +import 'package:new_budget/src/features/transactions/domain/entities/transaction.dart'; +import 'package:new_budget/src/features/user/application/active_user_controller.dart'; +import 'package:new_budget/src/features/user/domain/entities/user.dart'; + +// ─── Fakes / данные ────────────────────────────────────────────────────────── + +const _userId = 'test-uid'; + +class FakeActiveUserController extends ActiveUserController { + @override + Future build() async => + User(id: _userId, name: 'Test', createdAt: DateTime(2024)); +} + +Transaction _tx( + String id, + int amount, { + SpendingObligation? obligation, + SpendingImpulse? impulse, +}) => + Transaction( + id: id, + userId: _userId, + accountId: 'a', + type: TransactionType.expense, + amount: amount, + date: DateTime(2026, 6, 10), + obligation: obligation, + impulse: impulse, + createdAt: DateTime(2026, 6, 10), + ); + +final _txs = [ + _tx('1', 1000, obligation: SpendingObligation.required), + _tx('2', 2000, + obligation: SpendingObligation.unnecessary, + impulse: SpendingImpulse.impulsive), + _tx('3', 500, + obligation: SpendingObligation.optional, + impulse: SpendingImpulse.considered), +]; + +Widget _buildScreen() => ProviderScope( + overrides: [ + activeUserControllerProvider + .overrideWith(() => FakeActiveUserController()), + habitMonthTransactionsProvider(_userId).overrideWithValue(_txs), + categoriesStreamProvider(_userId) + .overrideWith((ref) => Stream>.value(const [])), + accountsStreamProvider(_userId) + .overrideWith((ref) => Stream>.value(const [])), + ], + child: MaterialApp( + theme: AppTheme.light(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const HabitAnalysisScreen(), + ), + ); + +ProviderContainer _container(WidgetTester tester) => + ProviderScope.containerOf(tester.element(find.byType(HabitAnalysisScreen))); + +/// Сегмент «Impulse» в строке фильтров (первый — пилюли в списке ниже). +Finder _impulseSegment() => find.text('Impulse').first; + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +void main() { + setUpAll(() { + GoogleFonts.config.allowRuntimeFetching = false; + }); + + testWidgets('по умолчанию: оба «All» активны, чип «Necessary» виден', + (tester) async { + await tester.pumpWidget(_buildScreen()); + await tester.pump(); // activeUserControllerProvider разрешается + + // «All» в обеих строках фильтров. + expect(find.text('All'), findsNWidgets(2)); + // Чип обязательности + пилюля tx1 в списке. + expect(find.text('Necessary'), findsNWidgets(2)); + }); + + testWidgets('выбор «Impulse» скрывает чип «Necessary»', (tester) async { + await tester.pumpWidget(_buildScreen()); + await tester.pump(); + + await tester.tap(_impulseSegment()); + // AnimatedSize — доигрываем анимацию. + await tester.pumpAndSettle(); + + // Чип скрыт, tx1 отфильтрована — «Necessary» нет нигде. + expect(find.text('Necessary'), findsNothing); + + // Возврат на «All» импульсивности возвращает чип. + await tester.tap(find.text('All').first); + await tester.pumpAndSettle(); + expect(find.text('Necessary'), findsNWidgets(2)); + }); + + testWidgets('выбор «Impulse» сбрасывает выбранную «Necessary» (каскад)', + (tester) async { + await tester.pumpWidget(_buildScreen()); + await tester.pump(); + + await tester.tap(find.text('Necessary').first); + await tester.pumpAndSettle(); + final container = _container(tester); + expect( + container.read(habitObligationFilterProvider), SpendingObligation.required); + + await tester.tap(_impulseSegment()); + await tester.pumpAndSettle(); + expect(container.read(habitObligationFilterProvider), isNull); + }); + + testWidgets('повторный тап по чипу обязательности снимает фильтр', + (tester) async { + await tester.pumpWidget(_buildScreen()); + await tester.pump(); + + await tester.tap(find.text('Unnecessary').first); + await tester.pumpAndSettle(); + final container = _container(tester); + expect(container.read(habitObligationFilterProvider), + SpendingObligation.unnecessary); + + await tester.tap(find.text('Unnecessary').first); + await tester.pumpAndSettle(); + expect(container.read(habitObligationFilterProvider), isNull); + }); +} diff --git a/test/features/notification_parsing/application/inbox_controller_test.dart b/test/features/notification_parsing/application/inbox_controller_test.dart index f2398c1..5e88f2a 100644 --- a/test/features/notification_parsing/application/inbox_controller_test.dart +++ b/test/features/notification_parsing/application/inbox_controller_test.dart @@ -3,7 +3,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:new_budget/src/core/database/converters/enum_converters.dart'; import 'package:new_budget/src/features/notification_parsing/application/inbox_controller.dart'; import 'package:new_budget/src/features/notification_parsing/application/notification_parsing_providers.dart'; -import 'package:new_budget/src/features/notification_parsing/data/parser/draft_codec.dart'; import 'package:new_budget/src/features/notification_parsing/domain/entities/account_binding.dart'; import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_draft.dart'; import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_rule.dart'; @@ -130,8 +129,7 @@ class _FakeParseRulesRepo implements ParseRulesRepository { final List> created = []; final List deletedIds = []; - /// Правила, которые вернёт [getByUser] (для проверки удаления существующего - /// merchant→category правила при пометке мерчанта как mixed). + /// Правила, которые вернёт [getByUser]. List existing = const []; @override @@ -140,9 +138,17 @@ class _FakeParseRulesRepo implements ParseRulesRepository { @override Future deleteById(String id) async => deletedIds.add(id); + @override + Future> getEnabledForApp( + String userId, + String packageName, + ) async => + existing; + @override Future create({ required String userId, + required String packageName, required ParseRuleKind kind, required MatchMode matchMode, required String pattern, @@ -153,6 +159,7 @@ class _FakeParseRulesRepo implements ParseRulesRepository { String? accountId, }) async { created.add({ + 'packageName': packageName, 'kind': kind, 'matchMode': matchMode, 'pattern': pattern, @@ -163,6 +170,7 @@ class _FakeParseRulesRepo implements ParseRulesRepository { return ParseRule( id: 'rule1', userId: userId, + packageName: packageName, kind: kind, matchMode: matchMode, pattern: pattern, @@ -296,6 +304,8 @@ void main() { expect(rulesRepo.created, hasLength(1)); expect(rulesRepo.created.single['kind'], ParseRuleKind.merchantToCategory); expect(rulesRepo.created.single['categoryId'], 'cat1'); + // Правило привязывается к приложению-источнику сообщения (per-app scope). + expect(rulesRepo.created.single['packageName'], 'ru.sberbankmobile'); // Тип операции фиксируется в правиле — gate-проверка typeMatchesRule. expect(rulesRepo.created.single['txType'], TransactionType.expense); @@ -352,54 +362,14 @@ void main() { }); }); - group('markMerchantMixed', () { - test('creates mixedMerchant rule, drops candidate, re-caches without ' - 'suggestion', () async { - await controller().markMerchantMixed( - userId: _userId, - message: _message(), - bundle: DraftBundle(draft: _draft()), - merchantCanonical: 'PYATEROCHKA', - ); + group('markApplied', () { + test('links message to a transaction saved via the full form', () async { + await controller().markApplied(_message(), 'tx-9'); - expect(rulesRepo.created, hasLength(1)); - expect(rulesRepo.created.single['kind'], ParseRuleKind.mixedMerchant); - expect(rulesRepo.created.single['pattern'], 'PYATEROCHKA'); - - // Кандидат снят, чтобы не предлагать правило снова. - expect(candidatesRepo.deleted, contains((_userId, 'PYATEROCHKA'))); - - // draftJson переписан без suggestion → карточка переключится на confirm. - expect(rawRepo.afterParse, hasLength(1)); - final draftJson = rawRepo.afterParse.single['draftJson'] as String; - expect(decodeDraftBundle(draftJson)?.suggestion, isNull); - - // Нет транзакции — пользователь категоризует вручную. + expect(rawRepo.linked, contains(('msg1', 'tx-9'))); + // Только линковка: ни транзакций, ни правил контроллер не создаёт. expect(txRepo.created, isEmpty); - }); - - test('removes an existing merchant→category rule for the merchant', - () async { - rulesRepo.existing = [ - ParseRule( - id: 'old-rule', - userId: _userId, - kind: ParseRuleKind.merchantToCategory, - matchMode: MatchMode.contains, - pattern: 'PYATEROCHKA', - categoryId: 'cat1', - createdAt: _now, - ), - ]; - - await controller().markMerchantMixed( - userId: _userId, - message: _message(), - bundle: DraftBundle(draft: _draft()), - merchantCanonical: 'PYATEROCHKA', - ); - - expect(rulesRepo.deletedIds, contains('old-rule')); + expect(rulesRepo.created, isEmpty); }); }); diff --git a/test/features/notification_parsing/application/parsing_pipeline_per_app_test.dart b/test/features/notification_parsing/application/parsing_pipeline_per_app_test.dart new file mode 100644 index 0000000..a8ac8b8 --- /dev/null +++ b/test/features/notification_parsing/application/parsing_pipeline_per_app_test.dart @@ -0,0 +1,212 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:new_budget/src/core/database/app_database.dart'; +import 'package:new_budget/src/core/database/converters/enum_converters.dart'; +import 'package:new_budget/src/core/providers/database_provider.dart'; +import 'package:new_budget/src/features/accounts/application/account_providers.dart'; +import 'package:new_budget/src/features/notification_parsing/application/ai_providers.dart'; +import 'package:new_budget/src/features/notification_parsing/application/notification_parsing_providers.dart'; +import 'package:new_budget/src/features/notification_parsing/application/parsing_settings_controller.dart'; +import 'package:new_budget/src/features/notification_parsing/application/parsing_worker.dart'; +import 'package:new_budget/src/features/notification_parsing/data/deepseek/deepseek_client.dart'; +import 'package:new_budget/src/features/notification_parsing/data/parser/ai_parser.dart'; +import 'package:new_budget/src/features/notification_parsing/data/parser/draft_codec.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/enums.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart'; + +/// Сквозные тесты per-app скоупа правил (parse_rules.packageName): правило, +/// созданное для одного приложения-источника, не должно срабатывать на +/// сообщениях другого. AI замокан (без сети) и отдаёт фиксированный draft-JSON. + +const _userId = 'u1'; +const _accountId = 'acc-default'; +const _bankA = 'ru.sberbankmobile'; +const _bankB = 'com.idamob.tinkoff.android'; + +AiParser _fakeAiParser({required String merchantRaw, required num amount}) { + final mock = MockClient((req) async { + final content = jsonEncode({ + 'type': 'expense', + 'kind': 'purchase', + 'amount': amount, + 'currency': 'RUB', + 'merchantRaw': merchantRaw, + }); + return http.Response( + jsonEncode({ + 'choices': [ + { + 'message': {'content': content}, + } + ], + 'usage': {'total_tokens': 42}, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + return AiParser(DeepSeekClient(client: mock, apiKey: 'k')); +} + +Future _seed(AppDatabase db) async { + await db.usersDao.insertUser( + UsersTableCompanion.insert(id: _userId, name: 'Test'), + ); + await db.accountsDao.insertAccount( + AccountsTableCompanion.insert(id: _accountId, userId: _userId, name: 'Main'), + ); + // Оба банка в allowlist — до правил доходят сообщения обоих. + await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert( + id: 'src-a', + userId: _userId, + packageName: _bankA, + )); + await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert( + id: 'src-b', + userId: _userId, + packageName: _bankB, + )); +} + +void _activateWorker(ProviderContainer c) { + c.read(parsingWorkerProvider(_userId)); +} + +Future _waitTerminal( + RawMessagesRepository repo, + String id, { + Duration timeout = const Duration(seconds: 10), +}) async { + const transient = { + RawMessageStatus.pending, + RawMessageStatus.parsing, + RawMessageStatus.pendingAi, + }; + final completer = Completer(); + late final StreamSubscription> sub; + sub = repo.watchAll(_userId).listen((list) { + for (final m in list) { + if (m.id == id && !transient.contains(m.status)) { + if (!completer.isCompleted) completer.complete(m); + return; + } + } + }); + try { + return await completer.future.timeout(timeout); + } finally { + await sub.cancel(); + } +} + +void main() { + late AppDatabase db; + late ProviderContainer container; + late RawMessagesRepository repo; + + Future bootstrap() async { + db = AppDatabase.forTesting(NativeDatabase.memory()); + await _seed(db); + container = ProviderContainer(overrides: [ + appDatabaseProvider.overrideWithValue(db), + isOnlineProvider.overrideWith((ref) => Stream.value(true)), + aiParserProvider.overrideWith( + (ref) async => _fakeAiParser(merchantRaw: 'LENTA', amount: 1500)), + ]); + repo = container.read(rawMessagesRepositoryProvider); + final settings = container.read(parsingSettingsControllerProvider.notifier); + await container.read(parsingSettingsControllerProvider.future); + await settings.setAiConsent(true); + await container + .read(accountRepositoryProvider) + .setDefault(_accountId, _userId); + } + + tearDown(() async { + container.dispose(); + await db.close(); + }); + + test('ignore-правило bankA: bankA → ignored, то же тело от bankB → inbox', + () async { + await bootstrap(); + await container.read(parseRulesRepositoryProvider).create( + userId: _userId, + packageName: _bankA, + kind: ParseRuleKind.ignore, + matchMode: MatchMode.contains, + pattern: 'Доставлен заказ', + ); + _activateWorker(container); + + const body = 'Доставлен заказ №123 на сумму 1500 руб'; + final fromA = await repo.insertIncoming( + userId: _userId, + packageName: _bankA, + body: body, + receivedAt: DateTime(2026, 7, 1, 12), + ); + expect((await _waitTerminal(repo, fromA.id)).status, + RawMessageStatus.ignored, + reason: 'ignore-правило действует в своём приложении (pre-AI путь)'); + + final fromB = await repo.insertIncoming( + userId: _userId, + packageName: _bankB, + body: body, + receivedAt: DateTime(2026, 7, 1, 13), + ); + expect( + (await _waitTerminal(repo, fromB.id)).status, RawMessageStatus.inbox, + reason: 'ignore-правило bankA не должно протекать в bankB'); + }); + + test('merchant-правило bankA: тот же мерчант от bankB → inbox с suggestion', + () async { + await bootstrap(); + await db.categoriesDao.insertCategory(CategoriesTableCompanion.insert( + id: 'cat-1', + userId: _userId, + name: 'Продукты', + )); + await container.read(parseRulesRepositoryProvider).create( + userId: _userId, + packageName: _bankA, + kind: ParseRuleKind.merchantToCategory, + matchMode: MatchMode.contains, + pattern: 'LENTA', + txType: TransactionType.expense, + categoryId: 'cat-1', + ); + _activateWorker(container); + + final fromB = await repo.insertIncoming( + userId: _userId, + packageName: _bankB, + body: 'Payment of 1500 RUB at LENTA', + receivedAt: DateTime(2026, 7, 1, 12), + ); + + final msg = await _waitTerminal(repo, fromB.id); + expect(msg.status, RawMessageStatus.inbox, + reason: 'правило bankA не видно в bankB → нет auto-apply'); + + // Мерчант «незнакомый» в скоупе bankB → pipeline предлагает создать правило. + final bundle = decodeDraftBundle(msg.draftJson); + expect(bundle, isNotNull); + expect(bundle!.suggestion, isNotNull, + reason: 'для незнакомого в этом приложении мерчанта нужна suggestion'); + expect(bundle.suggestion!.merchantCanonical, 'LENTA'); + + // Транзакция не создана — правило чужого приложения не применилось. + final txns = await db.select(db.transactionsTable).get(); + expect(txns, isEmpty); + }); +} diff --git a/test/features/notification_parsing/application/parsing_pipeline_mixed_merchant_test.dart b/test/features/notification_parsing/application/parsing_pipeline_self_merchant_test.dart similarity index 67% rename from test/features/notification_parsing/application/parsing_pipeline_mixed_merchant_test.dart rename to test/features/notification_parsing/application/parsing_pipeline_self_merchant_test.dart index 244c032..081d9e5 100644 --- a/test/features/notification_parsing/application/parsing_pipeline_mixed_merchant_test.dart +++ b/test/features/notification_parsing/application/parsing_pipeline_self_merchant_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:drift/native.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -20,16 +21,21 @@ import 'package:new_budget/src/features/notification_parsing/domain/entities/raw import 'package:new_budget/src/features/notification_parsing/domain/enums.dart'; import 'package:new_budget/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart'; -/// Сквозные тесты подавления предложения правила для mixed-мерчанта (§9.1): -/// знакомый мерчант без маркера → Inbox с suggestion; с маркером -/// `mixedMerchant` → Inbox без suggestion (карточка покажет «подтвердить -/// разово»). AI замокан (без сети) и отдаёт фиксированный draft-JSON. +/// Сквозные тесты источников с флагом «мерчант — само приложение» +/// (source_apps.selfMerchant): без флага → Inbox с предложением правила; +/// с флагом → Inbox без suggestion и без AI-подсказки категории (карточка +/// покажет ручной выбор категории). AI замокан (без сети) и отдаёт +/// фиксированный draft-JSON. const _userId = 'u1'; const _accountId = 'acc-default'; const _bank = 'ru.sberbankmobile'; -AiParser _fakeAiParser({required String merchantRaw, required num amount}) { +AiParser _fakeAiParser({ + required String merchantRaw, + required num amount, + String? categorySuggestion, +}) { final mock = MockClient((req) async { final content = jsonEncode({ 'type': 'expense', @@ -37,6 +43,7 @@ AiParser _fakeAiParser({required String merchantRaw, required num amount}) { 'amount': amount, 'currency': 'RUB', 'merchantRaw': merchantRaw, + 'categorySuggestion': ?categorySuggestion, }); return http.Response( jsonEncode({ @@ -54,7 +61,7 @@ AiParser _fakeAiParser({required String merchantRaw, required num amount}) { return AiParser(DeepSeekClient(client: mock, apiKey: 'k')); } -Future _seed(AppDatabase db) async { +Future _seed(AppDatabase db, {bool selfMerchant = false}) async { await db.usersDao.insertUser( UsersTableCompanion.insert(id: _userId, name: 'Test'), ); @@ -63,12 +70,15 @@ Future _seed(AppDatabase db) async { ); await db.sourceAppsDao.insert( SourceAppsTableCompanion.insert( - id: 'src-1', userId: _userId, packageName: _bank), + id: 'src-1', + userId: _userId, + packageName: _bank, + selfMerchant: Value(selfMerchant), + ), ); } void _activateWorker(ProviderContainer c) { - c.listen(pendingMessagesProvider(_userId), (_, _) {}, fireImmediately: true); c.read(parsingWorkerProvider(_userId)); } @@ -104,14 +114,19 @@ void main() { late ProviderContainer container; late RawMessagesRepository repo; - Future bootstrap() async { + Future bootstrap({ + bool selfMerchant = false, + String? categorySuggestion, + }) async { db = AppDatabase.forTesting(NativeDatabase.memory()); - await _seed(db); + await _seed(db, selfMerchant: selfMerchant); container = ProviderContainer(overrides: [ appDatabaseProvider.overrideWithValue(db), isOnlineProvider.overrideWith((ref) => Stream.value(true)), - aiParserProvider.overrideWith( - (ref) async => _fakeAiParser(merchantRaw: 'OZON', amount: 1500)), + aiParserProvider.overrideWith((ref) async => _fakeAiParser( + merchantRaw: 'OZON', + amount: 1500, + categorySuggestion: categorySuggestion)), ]); repo = container.read(rawMessagesRepositoryProvider); final settings = container.read(parsingSettingsControllerProvider.notifier); @@ -127,8 +142,7 @@ void main() { await db.close(); }); - test('знакомый мерчант без mixed-маркера → Inbox с предложением правила', - () async { + test('источник без флага → Inbox с предложением правила', () async { await bootstrap(); _activateWorker(container); @@ -145,20 +159,12 @@ void main() { final bundle = decodeDraftBundle(msg.draftJson); expect(bundle, isNotNull); expect(bundle!.suggestion, isNotNull, - reason: 'без маркера должно быть предложение правила'); + reason: 'без флага должно быть предложение правила'); expect(bundle.suggestion!.merchantCanonical, 'OZON'); }); - test('mixedMerchant-маркер → Inbox без предложения правила (suggestion null)', - () async { - await bootstrap(); - // Пользователь пометил мерчанта как «категории различаются». - await container.read(parseRulesRepositoryProvider).create( - userId: _userId, - kind: ParseRuleKind.mixedMerchant, - matchMode: MatchMode.contains, - pattern: 'OZON', - ); + test('selfMerchant-источник → Inbox без предложения правила', () async { + await bootstrap(selfMerchant: true); _activateWorker(container); final inserted = await repo.insertIncoming( @@ -169,15 +175,37 @@ void main() { ); final msg = await _waitTerminal(repo, inserted.id); - // Маркер не применяет действий → не авто-применяется, остаётся в Inbox. + // Правила нет → не авто-применяется, остаётся в Inbox. expect(msg.status, RawMessageStatus.inbox); final bundle = decodeDraftBundle(msg.draftJson); expect(bundle, isNotNull); expect(bundle!.suggestion, isNull, - reason: 'маркер mixedMerchant должен подавлять предложение правила'); + reason: 'флаг selfMerchant должен подавлять предложение правила'); // Транзакция не создаётся — пользователь категоризует вручную. final txns = await db.select(db.transactionsTable).get(); expect(txns, isEmpty); }); + + test('selfMerchant-источник глушит AI-подсказку категории в бандле', + () async { + await bootstrap(selfMerchant: true, categorySuggestion: 'Продукты'); + _activateWorker(container); + + final inserted = await repo.insertIncoming( + userId: _userId, + packageName: _bank, + body: 'Покупка 1500 RUB OZON', + receivedAt: DateTime(2026, 5, 31, 12), + ); + + final msg = await _waitTerminal(repo, inserted.id); + expect(msg.status, RawMessageStatus.inbox); + + final bundle = decodeDraftBundle(msg.draftJson); + expect(bundle, isNotNull); + expect(bundle!.draft.categorySuggestion, isNull, + reason: 'AI-подсказка категории должна глушиться для selfMerchant'); + expect(bundle.suggestion, isNull); + }); } diff --git a/test/features/notification_parsing/application/parsing_worker_allowlist_test.dart b/test/features/notification_parsing/application/parsing_worker_allowlist_test.dart index 156c366..f8324a3 100644 --- a/test/features/notification_parsing/application/parsing_worker_allowlist_test.dart +++ b/test/features/notification_parsing/application/parsing_worker_allowlist_test.dart @@ -73,7 +73,6 @@ Future _seed(AppDatabase db) async { } void _activateWorker(ProviderContainer c) { - c.listen(pendingMessagesProvider(_userId), (_, _) {}, fireImmediately: true); c.read(parsingWorkerProvider(_userId)); } @@ -177,6 +176,7 @@ void main() { // Ignore-правило: «Доставлен заказ» пропускать. await container.read(parseRulesRepositoryProvider).create( userId: _userId, + packageName: _bank, kind: ParseRuleKind.ignore, matchMode: MatchMode.contains, pattern: 'Доставлен заказ', @@ -229,6 +229,7 @@ void main() { )); await container.read(parseRulesRepositoryProvider).create( userId: _userId, + packageName: _bank, kind: ParseRuleKind.merchantToCategory, matchMode: MatchMode.contains, pattern: 'LENTA', diff --git a/test/features/notification_parsing/application/transfer_pairing_pipeline_test.dart b/test/features/notification_parsing/application/transfer_pairing_pipeline_test.dart new file mode 100644 index 0000000..14c16a6 --- /dev/null +++ b/test/features/notification_parsing/application/transfer_pairing_pipeline_test.dart @@ -0,0 +1,434 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:new_budget/src/core/database/app_database.dart'; +import 'package:new_budget/src/core/database/converters/enum_converters.dart'; +import 'package:new_budget/src/core/providers/database_provider.dart'; +import 'package:new_budget/src/features/notification_parsing/application/ai_providers.dart'; +import 'package:new_budget/src/features/notification_parsing/application/inbox_controller.dart'; +import 'package:new_budget/src/features/notification_parsing/application/notification_parsing_providers.dart'; +import 'package:new_budget/src/features/notification_parsing/application/parsing_pipeline.dart'; +import 'package:new_budget/src/features/notification_parsing/application/parsing_settings_controller.dart'; +import 'package:new_budget/src/features/notification_parsing/application/parsing_worker.dart'; +import 'package:new_budget/src/features/notification_parsing/data/deepseek/deepseek_client.dart'; +import 'package:new_budget/src/features/notification_parsing/data/parser/ai_parser.dart'; +import 'package:new_budget/src/features/notification_parsing/data/parser/draft_codec.dart'; +import 'package:new_budget/src/features/notification_parsing/data/parser/transfer_pair_matcher.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/enums.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart'; +import 'package:new_budget/src/features/transactions/application/transaction_providers.dart'; + +/// Сквозные тесты склейки переводов (transfer pairing): waitingPair → sweep → +/// merged-карточка / доклейка / релиз по таймауту / расклейка. AI замокан: +/// по телу сообщения возвращает transfer_out («Вы перевели …») либо +/// transfer_in («Пополнение …»). + +const _userId = 'u1'; +const _accA = 'acc-a'; +const _accB = 'acc-b'; +const _bankA = 'ru.sberbankmobile'; +const _bankB = 'com.idamob.tinkoff.android'; + +const _outBody = 'Вы перевели 5000 ₽ на счёт в другом банке'; +const _inBody = 'Пополнение 5000 ₽ переводом из банка'; +const _amountMinor = 500000; + +AiParser _pairAiParser() { + final mock = MockClient((req) async { + // Матчим ПОЛНЫЙ текст уведомления: system-промпт сам содержит слова + // «перевели»/«Пополнение» (правила выбора kind), по подстроке нельзя. + final isOut = req.body.contains(_outBody); + final content = jsonEncode({ + 'type': isOut ? 'expense' : 'income', + 'kind': isOut ? 'transfer_out' : 'transfer_in', + 'amount': 5000, + 'currency': 'RUB', + }); + return http.Response( + jsonEncode({ + 'choices': [ + { + 'message': {'content': content}, + } + ], + 'usage': {'total_tokens': 42}, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + return AiParser(DeepSeekClient(client: mock, apiKey: 'k')); +} + +Future _seed(AppDatabase db) async { + await db.usersDao.insertUser( + UsersTableCompanion.insert(id: _userId, name: 'Test'), + ); + await db.accountsDao.insertAccount( + AccountsTableCompanion.insert(id: _accA, userId: _userId, name: 'A'), + ); + await db.accountsDao.insertAccount( + AccountsTableCompanion.insert(id: _accB, userId: _userId, name: 'B'), + ); + await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert( + id: 'src-a', + userId: _userId, + packageName: _bankA, + )); + await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert( + id: 'src-b', + userId: _userId, + packageName: _bankB, + )); +} + +void _activateWorker(ProviderContainer c) { + c.read(parsingWorkerProvider(_userId)); +} + +/// Ждёт, пока сообщение [id] не пройдёт [test] (по стриму watchAll). +Future _waitFor( + RawMessagesRepository repo, + String id, + bool Function(RawMessage) test, { + Duration timeout = const Duration(seconds: 10), +}) async { + final completer = Completer(); + late final StreamSubscription> sub; + sub = repo.watchAll(_userId).listen((list) { + for (final m in list) { + if (m.id == id && test(m)) { + if (!completer.isCompleted) completer.complete(m); + return; + } + } + }); + try { + return await completer.future.timeout( + timeout, + onTimeout: () async { + final m = await repo.findById(id); + throw TimeoutException( + 'message $id stuck: status=${m?.status}, paired=${m?.pairedWithId}'); + }, + ); + } finally { + await sub.cancel(); + } +} + +void main() { + late AppDatabase db; + late ProviderContainer container; + late RawMessagesRepository repo; + + Future bootstrap() async { + db = AppDatabase.forTesting(NativeDatabase.memory()); + await _seed(db); + container = ProviderContainer(overrides: [ + appDatabaseProvider.overrideWithValue(db), + isOnlineProvider.overrideWith((ref) => Stream.value(true)), + aiParserProvider.overrideWith((ref) async => _pairAiParser()), + ]); + repo = container.read(rawMessagesRepositoryProvider); + final settings = container.read(parsingSettingsControllerProvider.notifier); + await container.read(parsingSettingsControllerProvider.future); + await settings.setAiConsent(true); + // Однозначные привязки: bankA → accA, bankB → accB (resolver #3, trusted). + final bindings = container.read(accountBindingsRepositoryProvider); + await bindings.create( + userId: _userId, packageName: _bankA, accountId: _accA); + await bindings.create( + userId: _userId, packageName: _bankB, accountId: _accB); + } + + tearDown(() async { + container.dispose(); + await db.close(); + }); + + Future forceDeadlinePast(String id) => (db.update(db.rawMessagesTable) + ..where((t) => t.id.equals(id))) + .write(RawMessagesTableCompanion( + pairDeadline: Value(DateTime.now().subtract(const Duration(minutes: 1))), + )); + + /// out (bankA) + in (bankB) в окне → склейка воркером. + Future<(RawMessage out, RawMessage inn)> mergePairFlow() async { + final t0 = DateTime(2026, 7, 1, 12); + final out = await repo.insertIncoming( + userId: _userId, + packageName: _bankA, + body: _outBody, + receivedAt: t0, + ); + final inn = await repo.insertIncoming( + userId: _userId, + packageName: _bankB, + body: _inBody, + receivedAt: t0.add(const Duration(minutes: 1)), + ); + final mergedOut = await _waitFor( + repo, + out.id, + (m) => m.status == RawMessageStatus.inbox && m.pairedWithId == inn.id, + ); + final pairedIn = await _waitFor( + repo, + inn.id, + (m) => m.status == RawMessageStatus.paired, + ); + return (mergedOut, pairedIn); + } + + test('склейка: out+in в окне → primary inbox (merged draft), secondary paired', + () async { + await bootstrap(); + _activateWorker(container); + + final (out, inn) = await mergePairFlow(); + + final bundle = decodeDraftBundle(out.draftJson)!; + expect(bundle.pairedRawMessageId, inn.id); + expect(bundle.draft.type, TransactionType.transfer); + expect(bundle.draft.accountId, _accA); + expect(bundle.draft.transferToAccountId, _accB); + expect(bundle.draft.categoryId, isNull); + expect(bundle.preMerge?['type'], 'expense', + reason: 'снапшот исходного типа для расклейки'); + + expect(inn.pairedWithId, out.id); + final innBundle = decodeDraftBundle(inn.draftJson)!; + expect(innBundle.draft.kind, TxKind.transferIn, + reason: 'одиночный draft secondary не перезаписывается'); + expect(innBundle.pairedRawMessageId, isNull); + + // Транзакций нет — склеенная пара всегда ждёт подтверждения. + expect(await db.select(db.transactionsTable).get(), isEmpty); + }); + + test('confirmPair → одна transfer-транзакция, обе половинки applied', + () async { + await bootstrap(); + _activateWorker(container); + + final (out, _) = await mergePairFlow(); + final bundle = decodeDraftBundle(out.draftJson)!; + + await container.read(inboxControllerProvider.notifier).confirmPair( + userId: _userId, + message: out, + draft: bundle.draft, + secondaryId: bundle.pairedRawMessageId!, + fromAccountId: _accA, + toAccountId: _accB, + ); + + final txns = await db.select(db.transactionsTable).get(); + expect(txns, hasLength(1)); + expect(txns.single.type, TransactionType.transfer); + expect(txns.single.accountId, _accA); + expect(txns.single.transferToAccountId, _accB); + expect(txns.single.amount, _amountMinor); + expect(txns.single.rawMessageId, out.id); + + final outAfter = await repo.findById(out.id); + final innAfter = await repo.findById(bundle.pairedRawMessageId!); + expect(outAfter!.status, RawMessageStatus.applied); + expect(innAfter!.status, RawMessageStatus.applied); + expect(outAfter.transactionId, txns.single.id); + expect(innAfter.transactionId, txns.single.id); + }); + + test('таймаут: релиз в Inbox одиночным черновиком, draft не затёрт', + () async { + await bootstrap(); + _activateWorker(container); + + final out = await repo.insertIncoming( + userId: _userId, + packageName: _bankA, + body: _outBody, + receivedAt: DateTime(2026, 7, 1, 12), + ); + final waiting = await _waitFor( + repo, out.id, (m) => m.status == RawMessageStatus.waitingPair); + expect(waiting.pairDeadline, isNotNull); + + await forceDeadlinePast(out.id); + await container.read(parsingPipelineProvider).sweepPairs(_userId); + + final released = await _waitFor( + repo, out.id, (m) => m.status == RawMessageStatus.inbox); + final bundle = decodeDraftBundle(released.draftJson)!; + expect(bundle.draft.kind, TxKind.transferOut); + expect(bundle.draft.amount, _amountMinor); + expect(bundle.pairedRawMessageId, isNull); + expect(released.confidenceAmount, isNotNull, + reason: 'релиз не должен затирать confidence-оценки'); + }); + + test('inbox-контрпартнёр: релизнутая половинка склеивается со второй', + () async { + await bootstrap(); + _activateWorker(container); + + final t0 = DateTime(2026, 7, 1, 12); + final out = await repo.insertIncoming( + userId: _userId, + packageName: _bankA, + body: _outBody, + receivedAt: t0, + ); + await _waitFor(repo, out.id, (m) => m.status == RawMessageStatus.waitingPair); + await forceDeadlinePast(out.id); + await container.read(parsingPipelineProvider).sweepPairs(_userId); + await _waitFor(repo, out.id, (m) => m.status == RawMessageStatus.inbox); + + // Вторая половинка приходит в окне receivedAt первой (которая уже в Inbox). + final inn = await repo.insertIncoming( + userId: _userId, + packageName: _bankB, + body: _inBody, + receivedAt: t0.add(const Duration(minutes: 4)), + ); + + final mergedOut = await _waitFor( + repo, + out.id, + (m) => m.status == RawMessageStatus.inbox && m.pairedWithId == inn.id, + ); + final pairedIn = await _waitFor( + repo, inn.id, (m) => m.status == RawMessageStatus.paired); + expect(pairedIn.pairedWithId, out.id); + final bundle = decodeDraftBundle(mergedOut.draftJson)!; + expect(bundle.draft.type, TransactionType.transfer); + expect(bundle.draft.transferToAccountId, _accB); + }); + + test('доклейка: applied-контрпартнёр конвертируется в transfer + откат', + () async { + await bootstrap(); + final settings = container.read(parsingSettingsControllerProvider.notifier); + // Первая половинка проходит обычным путём (пейринг выключен) и + // подтверждается пользователем как обычный расход. + await settings.setTransferPairingEnabled(false); + _activateWorker(container); + + final t0 = DateTime(2026, 7, 1, 12); + final out = await repo.insertIncoming( + userId: _userId, + packageName: _bankA, + body: _outBody, + receivedAt: t0, + ); + final outInbox = await _waitFor( + repo, out.id, (m) => m.status == RawMessageStatus.inbox); + final outBundle = decodeDraftBundle(outInbox.draftJson)!; + await container.read(inboxControllerProvider.notifier).confirmOnce( + userId: _userId, + message: outInbox, + draft: outBundle.draft, + accountId: _accA, + ); + await _waitFor(repo, out.id, + (m) => m.status == RawMessageStatus.applied && m.transactionId != null); + + await settings.setTransferPairingEnabled(true); + final inn = await repo.insertIncoming( + userId: _userId, + packageName: _bankB, + body: _inBody, + receivedAt: t0.add(const Duration(minutes: 2)), + ); + + final merged = await _waitFor( + repo, + inn.id, + (m) => m.status == RawMessageStatus.applied && m.transactionId != null, + ); + final txRepo = container.read(transactionRepositoryProvider); + final tx = (await txRepo.findById(merged.transactionId!))!; + expect(tx.type, TransactionType.transfer); + expect(tx.accountId, _accA); + expect(tx.transferToAccountId, _accB); + expect(tx.categoryId, isNull); + + final innBundle = decodeDraftBundle(merged.draftJson)!; + expect(innBundle.mergeUndo, isNotNull); + expect(innBundle.mergeUndo!['prevType'], 'expense'); + expect(merged.pairedWithId, out.id); + + // Откат доклейки из журнала. + await container.read(inboxControllerProvider.notifier).unmergeApplied( + userId: _userId, + message: merged, + ); + final restoredTx = (await txRepo.findById(tx.id))!; + expect(restoredTx.type, TransactionType.expense); + expect(restoredTx.transferToAccountId, isNull); + + final innAfter = (await repo.findById(inn.id))!; + expect(innAfter.status, RawMessageStatus.inbox); + expect(innAfter.transactionId, isNull); + expect(innAfter.pairedWithId, isNull); + expect(decodeDraftBundle(innAfter.draftJson)!.mergeUndo, isNull); + + final blocked = await container + .read(transferPairingBlocklistRepositoryProvider) + .contains(_userId, pairSignature(out.id, inn.id)); + expect(blocked, isTrue); + }); + + test('расклейка из Inbox: preMerge восстановлен, повторный матч заблокирован', + () async { + await bootstrap(); + _activateWorker(container); + + final (out, inn) = await mergePairFlow(); + + await container.read(inboxControllerProvider.notifier).unpair( + userId: _userId, + message: out, + ); + + final outAfter = (await repo.findById(out.id))!; + expect(outAfter.status, RawMessageStatus.inbox); + expect(outAfter.pairedWithId, isNull); + final outBundle = decodeDraftBundle(outAfter.draftJson)!; + expect(outBundle.pairedRawMessageId, isNull); + expect(outBundle.draft.type, TransactionType.expense, + reason: 'тип восстановлен из preMerge'); + expect(outBundle.draft.transferToAccountId, isNull); + + final innAfter = (await repo.findById(inn.id))!; + expect(innAfter.status, RawMessageStatus.inbox); + expect(innAfter.pairedWithId, isNull); + expect(decodeDraftBundle(innAfter.draftJson)!.draft.kind, + TxKind.transferIn); + + final blocked = await container + .read(transferPairingBlocklistRepositoryProvider) + .contains(_userId, pairSignature(out.id, inn.id)); + expect(blocked, isTrue); + + // Повторный матч заблокирован: возвращаем secondary в ожидание пары — + // sweep не должен склеить её с primary, стоящим в Inbox. + await repo.holdForPairing( + id: inn.id, + draftJson: innAfter.draftJson!, + deadline: DateTime.now().add(const Duration(minutes: 5)), + ); + await container.read(parsingPipelineProvider).sweepPairs(_userId); + final stillWaiting = (await repo.findById(inn.id))!; + expect(stillWaiting.status, RawMessageStatus.waitingPair, + reason: 'blocklist не даёт склеить ту же пару снова'); + }); +} diff --git a/test/features/notification_parsing/data/raw_messages_top_categories_test.dart b/test/features/notification_parsing/data/raw_messages_top_categories_test.dart new file mode 100644 index 0000000..052e789 --- /dev/null +++ b/test/features/notification_parsing/data/raw_messages_top_categories_test.dart @@ -0,0 +1,119 @@ +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:new_budget/src/core/database/app_database.dart'; +import 'package:new_budget/src/core/database/converters/enum_converters.dart'; +import 'package:new_budget/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart'; +import 'package:new_budget/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart'; + +/// `watchTopCategoryIds` — данные для чипов «частых категорий» на карточке +/// Inbox: категории подтверждённых транзакций из уведомлений одного +/// приложения, по числу транзакций (при равенстве — по свежести), максимум 4. + +const _userId = 'u1'; +const _accountId = 'acc1'; +const _ozon = 'ru.ozon.app.android'; +const _bank = 'ru.sberbankmobile'; + +void main() { + late AppDatabase db; + late RawMessagesRepositoryImpl repo; + var seq = 0; + + setUp(() async { + db = AppDatabase.forTesting(NativeDatabase.memory()); + repo = RawMessagesRepositoryImpl(RawMessagesDao(db)); + seq = 0; + // FK-цепочка: user → account → categories. + await db.usersDao.insertUser( + UsersTableCompanion.insert(id: _userId, name: 'Test'), + ); + await db.accountsDao.insertAccount( + AccountsTableCompanion.insert(id: _accountId, userId: _userId, name: 'Main'), + ); + for (final id in ['cat-a', 'cat-b', 'cat-c', 'cat-d', 'cat-e']) { + await db.categoriesDao.insertCategory( + CategoriesTableCompanion.insert(id: id, userId: _userId, name: id), + ); + } + }); + + tearDown(() => db.close()); + + /// Транзакция, «подтверждённая» из уведомления [packageName] + /// (rawMessageId != null); при packageName == null — ручная, без сообщения. + Future confirmTx({ + String? packageName = _ozon, + String? categoryId, + required DateTime date, + TransactionType type = TransactionType.expense, + }) async { + final n = seq++; + String? msgId; + if (packageName != null) { + final msg = await repo.insertIncoming( + userId: _userId, + packageName: packageName, + body: 'Покупка №$n', + receivedAt: date, + ); + msgId = msg.id; + } + await db.into(db.transactionsTable).insert( + TransactionsTableCompanion.insert( + id: 'tx-$n', + userId: _userId, + accountId: _accountId, + categoryId: Value(categoryId), + type: Value(type), + amount: 100, + date: date, + rawMessageId: Value(msgId), + ), + ); + } + + test('порядок: по частоте, при равенстве — по свежести; лимит 4', () async { + DateTime day(int d) => DateTime(2026, 6, d); + + // cat-a ×3, cat-b ×2; cat-c/d/e ×1 — свежесть e > c > d. + for (final d in [1, 2, 3]) { + await confirmTx(categoryId: 'cat-a', date: day(d)); + } + for (final d in [4, 5]) { + await confirmTx(categoryId: 'cat-b', date: day(d)); + } + await confirmTx(categoryId: 'cat-d', date: day(6)); + await confirmTx(categoryId: 'cat-c', date: day(7)); + await confirmTx(categoryId: 'cat-e', date: day(8)); + + final top = await repo + .watchTopCategoryIds(_userId, _ozon, TransactionType.expense) + .first; + + // cat-d (5-я по рангу) не влезла в лимит 4. + expect(top, ['cat-a', 'cat-b', 'cat-e', 'cat-c']); + }); + + test('фильтры: тип, пакет, ручные транзакции и null-категории не считаются', + () async { + final date = DateTime(2026, 6, 10); + await confirmTx(categoryId: 'cat-a', date: date); + // Не должны попасть в выборку: + await confirmTx(categoryId: 'cat-b', date: date, type: TransactionType.income); + await confirmTx(categoryId: 'cat-c', date: date, packageName: _bank); + await confirmTx(categoryId: 'cat-d', date: date, packageName: null); + await confirmTx(categoryId: null, date: date); + + final top = await repo + .watchTopCategoryIds(_userId, _ozon, TransactionType.expense) + .first; + expect(top, ['cat-a']); + + // Income-выборка того же пакета видит только income-транзакцию. + final topIncome = await repo + .watchTopCategoryIds(_userId, _ozon, TransactionType.income) + .first; + expect(topIncome, ['cat-b']); + }); +} diff --git a/test/features/notification_parsing/integration/ai_processing_integration_test.dart b/test/features/notification_parsing/integration/ai_processing_integration_test.dart index 5606030..a68a7e9 100644 --- a/test/features/notification_parsing/integration/ai_processing_integration_test.dart +++ b/test/features/notification_parsing/integration/ai_processing_integration_test.dart @@ -103,12 +103,9 @@ Future _seed(AppDatabase db, {List categories = const []}) async { } } -/// Активирует воркер: помимо чтения самого провайдера держим прямого слушателя -/// на его входном потоке [pendingMessagesProvider]. Без внешнего слушателя -/// autoDispose-стрим не подписывается на Drift и `ref.listen` внутри воркера не -/// получает событий (в приложении эту роль играет `HomeScreen`). +/// Активирует воркер: его входы — прямые подписки на Drift-стримы репозитория, +/// поэтому одного чтения провайдера достаточно. void _activateWorker(ProviderContainer c) { - c.listen(pendingMessagesProvider(_userId), (_, _) {}, fireImmediately: true); c.read(parsingWorkerProvider(_userId)); } diff --git a/test/features/notification_parsing/parser/draft_codec_test.dart b/test/features/notification_parsing/parser/draft_codec_test.dart index 2ef52d7..71882e5 100644 --- a/test/features/notification_parsing/parser/draft_codec_test.dart +++ b/test/features/notification_parsing/parser/draft_codec_test.dart @@ -63,5 +63,56 @@ void main() { expect(decodeDraftBundle(null), isNull); expect(decodeDraftBundle(''), isNull); }); + + test('поля пейринга (pairedRawMessageId/preMerge/mergeUndo) round-trip', + () { + final draft = ParseDraft( + rawMessageId: 'm1', + type: TransactionType.transfer, + amount: 500000, + kind: TxKind.transferOut, + accountId: 'a1', + transferToAccountId: 'a2', + source: ParseSource.ai, + ); + final decoded = decodeDraftBundle(encodeDraftBundle( + draft, + null, + accountTrusted: true, + pairedRawMessageId: 'm2', + preMerge: {'type': 'expense', 'categoryId': 'c1'}, + mergeUndo: { + 'txId': 't1', + 'prevType': 'income', + 'prevAccountId': 'a2', + 'prevCategoryId': null, + 'prevTransferToAccountId': null, + }, + )); + expect(decoded, isNotNull); + expect(decoded!.accountTrusted, isTrue); + expect(decoded.pairedRawMessageId, 'm2'); + expect(decoded.preMerge, {'type': 'expense', 'categoryId': 'c1'}); + expect(decoded.mergeUndo!['txId'], 't1'); + expect(decoded.mergeUndo!['prevType'], 'income'); + expect(decoded.mergeUndo!['prevAccountId'], 'a2'); + expect(decoded.draft.transferToAccountId, 'a2'); + }); + + test('старый JSON без полей пейринга декодится с дефолтами', () { + final draft = ParseDraft( + rawMessageId: 'm1', + type: TransactionType.expense, + amount: 100, + source: ParseSource.ai, + ); + // encode без новых аргументов = формат до transfer pairing. + final decoded = decodeDraftBundle(encodeDraftBundle(draft, null)); + expect(decoded, isNotNull); + expect(decoded!.accountTrusted, isFalse); + expect(decoded.pairedRawMessageId, isNull); + expect(decoded.preMerge, isNull); + expect(decoded.mergeUndo, isNull); + }); }); } diff --git a/test/features/notification_parsing/parser/rule_lookup_test.dart b/test/features/notification_parsing/parser/rule_lookup_test.dart index db2268d..8e5e00f 100644 --- a/test/features/notification_parsing/parser/rule_lookup_test.dart +++ b/test/features/notification_parsing/parser/rule_lookup_test.dart @@ -154,40 +154,6 @@ void main() { }); }); - group('findMixedMerchantRule', () { - const body = 'Покупка OZON 1240'; - - test('finds enabled mixedMerchant rule matching the merchant', () { - final r = findMixedMerchantRule( - [ - _rule( - id: 'm', - pattern: 'OZON', - kind: ParseRuleKind.mixedMerchant, - categoryId: null), - ], - body: body, - ); - expect(r?.id, 'm'); - }); - - test('ignores merchantToCategory and disabled rules', () { - final r = findMixedMerchantRule( - [ - _rule(id: 'a', pattern: 'OZON'), - _rule( - id: 'b', - pattern: 'OZON', - kind: ParseRuleKind.mixedMerchant, - enabled: false, - categoryId: null), - ], - body: body, - ); - expect(r, isNull); - }); - }); - group('findIgnoreRule', () { test('finds enabled ignore rule', () { final r = findIgnoreRule( diff --git a/test/features/notification_parsing/parser/transfer_pair_matcher_test.dart b/test/features/notification_parsing/parser/transfer_pair_matcher_test.dart new file mode 100644 index 0000000..963b955 --- /dev/null +++ b/test/features/notification_parsing/parser/transfer_pair_matcher_test.dart @@ -0,0 +1,145 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:new_budget/src/core/database/converters/enum_converters.dart'; +import 'package:new_budget/src/features/notification_parsing/data/parser/transfer_pair_matcher.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_draft.dart'; +import 'package:new_budget/src/features/notification_parsing/domain/enums.dart'; + +ParseDraft _draft({ + String id = 'm', + TxKind? kind, + int amount = 500000, + String currency = 'RUB', + String? accountId, +}) => + ParseDraft( + rawMessageId: id, + type: kind == TxKind.transferOut + ? TransactionType.expense + : TransactionType.income, + amount: amount, + currency: currency, + kind: kind, + accountId: accountId, + source: ParseSource.ai, + ); + +void main() { + final t0 = DateTime(2026, 7, 1, 12); + + group('isTransferHalf', () { + test('transferOut/transferIn — да, остальное — нет', () { + expect(isTransferHalf(_draft(kind: TxKind.transferOut)), isTrue); + expect(isTransferHalf(_draft(kind: TxKind.transferIn)), isTrue); + expect(isTransferHalf(_draft(kind: TxKind.purchase)), isFalse); + expect(isTransferHalf(_draft(kind: TxKind.fee)), isFalse); + expect(isTransferHalf(_draft(kind: null)), isFalse); + }); + }); + + group('isPair', () { + test('противоположные kind, равные суммы, окно 5 мин → пара', () { + expect( + isPair( + a: _draft(kind: TxKind.transferOut, accountId: 'a1'), + aReceivedAt: t0, + b: _draft(kind: TxKind.transferIn, accountId: 'a2'), + bReceivedAt: t0.add(const Duration(minutes: 3)), + ), + isTrue, + ); + }); + + test('одинаковые kind → не пара', () { + expect( + isPair( + a: _draft(kind: TxKind.transferOut), + aReceivedAt: t0, + b: _draft(kind: TxKind.transferOut), + bReceivedAt: t0, + ), + isFalse, + ); + }); + + test('kind=null не участвует', () { + expect( + isPair( + a: _draft(kind: null), + aReceivedAt: t0, + b: _draft(kind: TxKind.transferIn), + bReceivedAt: t0, + ), + isFalse, + ); + }); + + test('разные суммы или валюты → не пара', () { + expect( + isPair( + a: _draft(kind: TxKind.transferOut, amount: 500000), + aReceivedAt: t0, + b: _draft(kind: TxKind.transferIn, amount: 500001), + bReceivedAt: t0, + ), + isFalse, + ); + expect( + isPair( + a: _draft(kind: TxKind.transferOut, currency: 'RUB'), + aReceivedAt: t0, + b: _draft(kind: TxKind.transferIn, currency: 'USD'), + bReceivedAt: t0, + ), + isFalse, + ); + }); + + test('вне окна 5 минут → не пара', () { + expect( + isPair( + a: _draft(kind: TxKind.transferOut), + aReceivedAt: t0, + b: _draft(kind: TxKind.transferIn), + bReceivedAt: t0.add(const Duration(minutes: 5, seconds: 1)), + ), + isFalse, + ); + }); + + test('оба счёта разрешены и совпали → не пара; один null → пара', () { + expect( + isPair( + a: _draft(kind: TxKind.transferOut, accountId: 'a1'), + aReceivedAt: t0, + b: _draft(kind: TxKind.transferIn, accountId: 'a1'), + bReceivedAt: t0, + ), + isFalse, + ); + expect( + isPair( + a: _draft(kind: TxKind.transferOut, accountId: 'a1'), + aReceivedAt: t0, + b: _draft(kind: TxKind.transferIn, accountId: null), + bReceivedAt: t0, + ), + isTrue, + ); + }); + }); + + group('pickNearest', () { + test('выбирает ближайшего по receivedAt', () { + final near = t0.add(const Duration(minutes: 1)); + final far = t0.add(const Duration(minutes: 4)); + expect(pickNearest([('far', far), ('near', near)], t0), 'near'); + }); + }); + + group('pairSignature', () { + test('порядконезависима', () { + expect(pairSignature('b', 'a'), pairSignature('a', 'b')); + expect(pairSignature('a', 'b'), 'a|b'); + }); + }); +} diff --git a/test/features/notification_parsing/presentation/inbox_card_test.dart b/test/features/notification_parsing/presentation/inbox_card_test.dart index e7a68ed..8b650c2 100644 --- a/test/features/notification_parsing/presentation/inbox_card_test.dart +++ b/test/features/notification_parsing/presentation/inbox_card_test.dart @@ -1,10 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:new_budget/l10n/app_localizations.dart'; import 'package:new_budget/src/app/theme/app_theme.dart'; import 'package:new_budget/src/core/database/converters/enum_converters.dart'; +import 'package:new_budget/src/features/accounts/application/accounts_controller.dart'; +import 'package:new_budget/src/features/accounts/domain/entities/account.dart'; import 'package:new_budget/src/features/categories/domain/entities/category.dart'; import 'package:new_budget/src/features/notification_parsing/application/inbox_controller.dart'; import 'package:new_budget/src/features/notification_parsing/data/parser/draft_codec.dart'; @@ -13,6 +17,7 @@ import 'package:new_budget/src/features/notification_parsing/domain/entities/raw import 'package:new_budget/src/features/notification_parsing/domain/entities/rule_suggestion.dart'; import 'package:new_budget/src/features/notification_parsing/domain/enums.dart'; import 'package:new_budget/src/features/notification_parsing/presentation/widgets/inbox_card.dart'; +import 'package:new_budget/src/features/transactions/presentation/screens/transaction_form_screen.dart'; final _now = DateTime(2026, 5, 29, 14, 5); @@ -20,10 +25,11 @@ class FakeInboxController extends InboxController { int createRuleCalls = 0; int confirmOnceCalls = 0; int ignoreCalls = 0; - int markMixedCalls = 0; String? lastCreateRuleCategory; + String? lastCreateRuleAccount; String? lastConfirmCategory; - String? lastMarkMixedMerchant; + String? lastConfirmAccount; + final List<(String, String)> appliedCalls = []; @override AsyncValue build() => const AsyncData(null); @@ -42,6 +48,7 @@ class FakeInboxController extends InboxController { }) async { createRuleCalls++; lastCreateRuleCategory = categoryId; + lastCreateRuleAccount = accountId; } @override @@ -55,17 +62,12 @@ class FakeInboxController extends InboxController { }) async { confirmOnceCalls++; lastConfirmCategory = categoryId; + lastConfirmAccount = accountId; } @override - Future markMerchantMixed({ - required String userId, - required RawMessage message, - required DraftBundle bundle, - required String merchantCanonical, - }) async { - markMixedCalls++; - lastMarkMixedMerchant = merchantCanonical; + Future markApplied(RawMessage message, String transactionId) async { + appliedCalls.add((message.id, transactionId)); } @override @@ -107,7 +109,7 @@ RawMessage _recognizedMessage() { } /// Уведомление без мерчанта (только сумма) → нет suggestion → карточка -/// показывает «подтвердить разово» без кнопки «Создать правило». +/// показывает «Подтвердить» без кнопки «Создать правило». /// [categorySuggestion] — AI-подсказка категории (по имени), которую карточка /// должна предзаполнить в пикере. RawMessage _amountOnlyMessage({String? categorySuggestion}) { @@ -132,14 +134,44 @@ RawMessage _amountOnlyMessage({String? categorySuggestion}) { ); } +const _categoryById = { + 'cat1': Category( + id: 'cat1', + userId: 'u1', + name: 'Продукты', + type: CategoryType.expense, + archived: false, + ), +}; + +/// Герметичные overrides по умолчанию: чипы «частых категорий» — пустой стрим +/// (иначе провайдер полез бы в реальную БД). +List _baseOverrides( + FakeInboxController fake, { + List topCategories = const [], +}) { + return [ + inboxControllerProvider.overrideWith(() => fake), + topConfirmCategoriesProvider( + 'u1', 'com.example.wallet', TransactionType.expense) + .overrideWith((ref) => Stream.value(topCategories)), + topConfirmCategoriesProvider( + 'u1', 'ru.sberbankmobile', TransactionType.expense) + .overrideWith((ref) => Stream.value(topCategories)), + ]; +} + Widget _host( FakeInboxController fake, RawMessage message, { String? defaultAccountId = 'acc1', + List topCategories = const [], + List extraOverrides = const [], }) { return ProviderScope( overrides: [ - inboxControllerProvider.overrideWith(() => fake), + ..._baseOverrides(fake, topCategories: topCategories), + ...extraOverrides, ], child: MaterialApp( theme: AppTheme.light(), @@ -151,15 +183,7 @@ Widget _host( message: message, userId: 'u1', defaultAccountId: defaultAccountId, - categoryById: const { - 'cat1': Category( - id: 'cat1', - userId: 'u1', - name: 'Продукты', - type: CategoryType.expense, - archived: false, - ), - }, + categoryById: _categoryById, ), ), ), @@ -197,7 +221,7 @@ void main() { expect(fake.lastCreateRuleCategory, 'cat1'); }); - testWidgets('tap confirm-once calls controller', (tester) async { + testWidgets('tap confirm calls controller', (tester) async { await tester.pumpWidget(_host(fake, _recognizedMessage())); await tester.pump(); @@ -223,18 +247,23 @@ void main() { await tester.pumpWidget(_host(fake, _amountOnlyMessage())); await tester.pump(); - // Нет мерчанта → кнопки «Создать правило» и «Категории различаются» нет. + // Нет мерчанта → кнопки «Создать правило» нет, есть «Подтвердить». expect(find.textContaining('Создать правило'), findsNothing); - expect(find.text(l10n.inboxMarkMixed), findsNothing); - - // Есть основная кнопка «Подтвердить». expect(find.text(l10n.inboxConfirm), findsOneWidget); + }); + + testWidgets('confirm inactive without category, hint is shown', + (tester) async { + final l10n = await AppLocalizations.delegate.load(const Locale('ru')); + await tester.pumpWidget(_host(fake, _amountOnlyMessage())); + await tester.pump(); + + // Категория не выбрана → подсказка видна, тап по «Подтвердить» — ничего. + expect(find.text(l10n.inboxCategoryRequiredHint), findsOneWidget); await tester.tap(find.text(l10n.inboxConfirm)); await tester.pump(); - - expect(fake.confirmOnceCalls, 1); - expect(fake.createRuleCalls, 0); + expect(fake.confirmOnceCalls, 0); }); testWidgets('confirm-once pre-fills category from AI hint and passes it', @@ -246,6 +275,8 @@ void main() { // AI-подсказка «Продукты» сматчена на cat1 и показана в пикере. expect(find.text('Продукты'), findsOneWidget); + // Категория есть → подсказки «выберите категорию» нет. + expect(find.text(l10n.inboxCategoryRequiredHint), findsNothing); await tester.tap(find.text(l10n.inboxConfirm)); await tester.pump(); @@ -254,38 +285,204 @@ void main() { expect(fake.lastConfirmCategory, 'cat1'); }); - testWidgets('confirm disabled when no account resolves', (tester) async { + testWidgets('category chip selects category and is passed to confirmOnce', + (tester) async { final l10n = await AppLocalizations.delegate.load(const Locale('ru')); - // Нет дефолтного счёта и draft.accountId == null → подтвердить нельзя. - await tester.pumpWidget( - _host(fake, _amountOnlyMessage(), defaultAccountId: null)); + await tester.pumpWidget(_host( + fake, + _amountOnlyMessage(), + topCategories: const ['cat1'], + )); await tester.pump(); - final button = tester.widget( - find.ancestor( - of: find.text(l10n.inboxConfirm), - matching: find.byType(FilledButton), - ), - ); - expect(button.onPressed, isNull); + // Чип «Продукты» виден; категория ещё не выбрана. + expect(find.text('Продукты'), findsOneWidget); + expect(find.text(l10n.inboxCategoryRequiredHint), findsOneWidget); + + await tester.tap(find.text('Продукты').first); + await tester.pump(); + + // Выбор чипа активирует «Подтвердить». + expect(find.text(l10n.inboxCategoryRequiredHint), findsNothing); await tester.tap(find.text(l10n.inboxConfirm)); await tester.pump(); - expect(fake.confirmOnceCalls, 0); + + expect(fake.confirmOnceCalls, 1); + expect(fake.lastConfirmCategory, 'cat1'); }); - testWidgets('"categories vary" marks merchant mixed', (tester) async { + testWidgets('create rule without account opens the account picker', + (tester) async { final l10n = await AppLocalizations.delegate.load(const Locale('ru')); - await tester.pumpWidget(_host(fake, _recognizedMessage())); + final account = Account( + id: 'acc-x', + userId: 'u1', + name: 'Основной', + type: AccountType.card, + currency: 'RUB', + initialBalance: 0, + archived: false, + createdAt: _now, + ); + // Нет дефолтного счёта и draft.accountId == null → «Создать правило» + // открывает пикер счёта вместо молчаливого no-op. + await tester.pumpWidget(_host( + fake, + _recognizedMessage(), + defaultAccountId: null, + extraOverrides: [ + accountsStreamProvider('u1') + .overrideWith((ref) => Stream.value([account])), + accountBalanceProvider('acc-x') + .overrideWith((ref) => Stream.value(0)), + ], + )); await tester.pump(); - expect(find.text(l10n.inboxMarkMixed), findsOneWidget); + await tester.tap(find.text(l10n.inboxCreateRule('PYATEROCHKA', 'Продукты'))); + await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.inboxMarkMixed)); + // Открылся пикер счёта, правило ещё не создано. + expect(find.text(l10n.pickerAccountTitle), findsOneWidget); + expect(fake.createRuleCalls, 0); + + // Имя счёта в тайле пикера встречается дважды (название + подпись). + await tester.tap(find.text('Основной').first); + await tester.pumpAndSettle(); + + expect(fake.createRuleCalls, 1); + expect(fake.lastCreateRuleAccount, 'acc-x'); + expect(fake.lastCreateRuleCategory, 'cat1'); + }); + + testWidgets('create rule: cancelling the account picker does nothing', + (tester) async { + final l10n = await AppLocalizations.delegate.load(const Locale('ru')); + await tester.pumpWidget(_host( + fake, + _recognizedMessage(), + defaultAccountId: null, + extraOverrides: [ + accountsStreamProvider('u1') + .overrideWith((ref) => Stream.value(const [])), + ], + )); await tester.pump(); - expect(fake.markMixedCalls, 1); - expect(fake.lastMarkMixedMerchant, 'PYATEROCHKA'); + await tester.tap(find.text(l10n.inboxCreateRule('PYATEROCHKA', 'Продукты'))); + await tester.pumpAndSettle(); + expect(find.text(l10n.pickerAccountTitle), findsOneWidget); + + // Тап по барьеру закрывает пикер без выбора. + await tester.tapAt(const Offset(20, 20)); + await tester.pumpAndSettle(); + + expect(fake.createRuleCalls, 0); + }); + + testWidgets('confirm without account opens the account picker', + (tester) async { + final l10n = await AppLocalizations.delegate.load(const Locale('ru')); + final account = Account( + id: 'acc-x', + userId: 'u1', + name: 'Основной', + type: AccountType.card, + currency: 'RUB', + initialBalance: 0, + archived: false, + createdAt: _now, + ); + // Нет дефолтного счёта и draft.accountId == null → «Подтвердить» открывает + // пикер счёта; выбор в нём завершает подтверждение. + await tester.pumpWidget(_host( + fake, + _amountOnlyMessage(categorySuggestion: 'Продукты'), + defaultAccountId: null, + extraOverrides: [ + accountsStreamProvider('u1') + .overrideWith((ref) => Stream.value([account])), + accountBalanceProvider('acc-x') + .overrideWith((ref) => Stream.value(0)), + ], + )); + await tester.pump(); + + await tester.tap(find.text(l10n.inboxConfirm)); + await tester.pumpAndSettle(); + + // Открылся пикер счёта. + expect(find.text(l10n.pickerAccountTitle), findsOneWidget); + expect(fake.confirmOnceCalls, 0); + + // Имя счёта в тайле пикера встречается дважды (название + подпись). + await tester.tap(find.text('Основной').first); + await tester.pumpAndSettle(); + + expect(fake.confirmOnceCalls, 1); + expect(fake.lastConfirmAccount, 'acc-x'); + }); + + testWidgets('pencil opens full form with prefill and links the saved tx', + (tester) async { + TransactionFormPrefill? capturedPrefill; + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold( + body: InboxCard( + message: _amountOnlyMessage(), + userId: 'u1', + defaultAccountId: 'acc1', + categoryById: _categoryById, + ), + ), + ), + GoRoute( + path: '/transactions/new', + builder: (context, state) { + capturedPrefill = state.extra as TransactionFormPrefill?; + return Scaffold( + body: TextButton( + onPressed: () => context.pop('tx-9'), + child: const Text('SAVE-STUB'), + ), + ); + }, + ), + ], + ); + + await tester.pumpWidget(ProviderScope( + overrides: _baseOverrides(fake), + child: MaterialApp.router( + routerConfig: router, + theme: AppTheme.light(), + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + ), + )); + await tester.pump(); + + // Карандаш активен даже без категории. + await tester.tap(find.byIcon(Icons.edit_outlined)); + await tester.pumpAndSettle(); + + expect(capturedPrefill, isNotNull); + expect(capturedPrefill!.amountMinor, 50000); + expect(capturedPrefill!.type, TransactionType.expense); + expect(capturedPrefill!.accountId, 'acc1'); + expect(capturedPrefill!.rawMessageId, 'msg2'); + + // «Сохранение» в форме возвращает id → сообщение линкуется. + await tester.tap(find.text('SAVE-STUB')); + await tester.pumpAndSettle(); + + expect(fake.appliedCalls, contains(('msg2', 'tx-9'))); + expect(fake.confirmOnceCalls, 0); }); testWidgets('unrecognized message shows manual-add fallback', (tester) async { diff --git a/test/features/transactions/presentation/transaction_form_screen_test.dart b/test/features/transactions/presentation/transaction_form_screen_test.dart index 454adb3..d9e9f11 100644 --- a/test/features/transactions/presentation/transaction_form_screen_test.dart +++ b/test/features/transactions/presentation/transaction_form_screen_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:new_budget/l10n/app_localizations.dart'; @@ -50,6 +51,7 @@ class TxCall { required this.date, this.merchant, this.transferToAccountId, + this.rawMessageId, }); final String userId; final String accountId; @@ -59,6 +61,7 @@ class TxCall { final DateTime date; final String? merchant; final String? transferToAccountId; + final String? rawMessageId; } class FakeTransactionsController extends TransactionsController { @@ -93,6 +96,7 @@ class FakeTransactionsController extends TransactionsController { date: date, merchant: merchant, transferToAccountId: transferToAccountId, + rawMessageId: rawMessageId, )); return Transaction( id: 'tx-1', @@ -118,9 +122,7 @@ class _UnusedTransactionRepo implements TransactionRepository { // ─── Helper ────────────────────────────────────────────────────────────────── -Widget _buildForm(FakeTransactionsController fakeCtrl) { - return ProviderScope( - overrides: [ +List _formOverrides(FakeTransactionsController fakeCtrl) => [ activeUserControllerProvider.overrideWith(() => FakeActiveUserController()), settingsControllerProvider('test-uid') .overrideWith(() => FakeSettingsController()), @@ -139,12 +141,19 @@ Widget _buildForm(FakeTransactionsController fakeCtrl) { categoriesByTypeStreamProvider('test-uid', CategoryType.income).overrideWith( (ref) => Stream>.value(const []), ), - ], + ]; + +Widget _buildForm( + FakeTransactionsController fakeCtrl, { + TransactionFormPrefill? prefill, +}) { + return ProviderScope( + overrides: _formOverrides(fakeCtrl), child: MaterialApp( theme: AppTheme.light(), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: const TransactionFormScreen(), + home: TransactionFormScreen(prefill: prefill), ), ); } @@ -346,4 +355,86 @@ void main() { expect(fakeCtrl.calls, hasLength(1)); expect(fakeCtrl.calls.first.merchant, isNull); }); + + // ── Префилл из Inbox ────────────────────────────────────────────────────── + + testWidgets('Префилл гидратирует поля; save передаёт rawMessageId', + (tester) async { + final testDate = DateTime(2026, 5, 29, 14, 5); + await tester.pumpWidget(_buildForm( + fakeCtrl, + prefill: TransactionFormPrefill( + type: TransactionType.expense, + amountMinor: 50000, + date: testDate, + accountId: 'a-1', + categoryId: 'cat-1', + merchant: 'Ozon', + rawMessageId: 'msg-2', + ), + )); + await tester.pump(); // activeUser разрешается + await tester.pump(); // post-frame гидрация черновика + + // Сумма из префилла видна в поле ввода. + expect(find.text('500'), findsOneWidget); + expect(find.text('Ozon'), findsOneWidget); + + await _tapSave(tester); + + expect(fakeCtrl.calls, hasLength(1)); + final call = fakeCtrl.calls.first; + expect(call.amount, 50000); + expect(call.accountId, 'a-1'); + expect(call.categoryId, 'cat-1'); + expect(call.date, testDate); + expect(call.merchant, 'Ozon'); + expect(call.rawMessageId, 'msg-2'); + }); + + testWidgets('Создание возвращает id транзакции через Navigator.pop', + (tester) async { + String? poppedId; + await tester.pumpWidget(ProviderScope( + overrides: _formOverrides(fakeCtrl), + child: MaterialApp( + theme: AppTheme.light(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () async { + poppedId = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => TransactionFormScreen( + prefill: TransactionFormPrefill( + type: TransactionType.expense, + amountMinor: 50000, + date: DateTime(2026, 5, 29), + accountId: 'a-1', + categoryId: 'cat-1', + rawMessageId: 'msg-2', + ), + ), + ), + ); + }, + child: const Text('OPEN'), + ), + ), + ), + ), + )); + + await tester.tap(find.text('OPEN')); + await tester.pumpAndSettle(); + + await _tapSave(tester); + await tester.pumpAndSettle(); + + // Форма закрылась и вернула id созданной транзакции (fake отдаёт 'tx-1'). + expect(poppedId, 'tx-1'); + expect(find.text('OPEN'), findsOneWidget); + }); } diff --git a/test/features/user/onboarding_screen_test.dart b/test/features/user/onboarding_screen_test.dart index a6d423a..60ebd87 100644 --- a/test/features/user/onboarding_screen_test.dart +++ b/test/features/user/onboarding_screen_test.dart @@ -6,6 +6,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:new_budget/l10n/app_localizations.dart'; import 'package:new_budget/src/app/theme/app_theme.dart'; +import 'package:new_budget/src/core/database/converters/enum_converters.dart'; +import 'package:new_budget/src/features/accounts/application/accounts_controller.dart'; +import 'package:new_budget/src/features/accounts/domain/entities/account.dart'; import 'package:new_budget/src/features/user/application/active_user_controller.dart'; import 'package:new_budget/src/features/user/application/users_controller.dart'; import 'package:new_budget/src/features/user/domain/entities/user.dart'; @@ -13,10 +16,9 @@ import 'package:new_budget/src/features/user/presentation/screens/onboarding_scr // ─── Fakes ──────────────────────────────────────────────────────────────────── // -// FakeUsersController и FakeActiveUserController расширяют реальные контроллеры -// и переопределяют только те методы, которые касаются БД. Riverpod создаёт их -// через фабрику в overrideWith, поэтому ref.watch/ref.read внутри build() -// никогда не вызываются. +// Fake-контроллеры расширяют реальные контроллеры и переопределяют только те +// методы, которые касаются БД. Riverpod создаёт их через фабрику в overrideWith, +// поэтому ref.watch/ref.read внутри build() никогда не вызываются. class FakeUsersController extends UsersController { /// Имена, с которыми вызывался createUser. @@ -40,6 +42,44 @@ class FakeUsersController extends UsersController { } } +class FakeAccountsController extends AccountsController { + final List<({String userId, String name, AccountType type})> created = []; + final List<(String? id, String userId)> setDefaultCalls = []; + + @override + AsyncValue build() => const AsyncData(null); + + @override + Future createAccount({ + required String userId, + required String name, + required AccountType type, + required String currency, + int initialBalance = 0, + int? iconCode, + int? colorValue, + }) async { + created.add((userId: userId, name: name, type: type)); + return Account( + id: 'acc-${created.length}', + userId: userId, + name: name, + type: type, + currency: currency, + initialBalance: initialBalance, + iconCode: iconCode, + colorValue: colorValue, + archived: false, + createdAt: DateTime(2024, 1, 1), + ); + } + + @override + Future setDefaultAccount(String? id, String userId) async { + setDefaultCalls.add((id, userId)); + } +} + class FakeActiveUserController extends ActiveUserController { /// Пользователи, переданные в setActiveUser. final List activatedUsers = []; @@ -59,11 +99,13 @@ class FakeActiveUserController extends ActiveUserController { Widget _buildOnboarding({ required FakeUsersController fakeUsers, + required FakeAccountsController fakeAccounts, required FakeActiveUserController fakeActive, }) { return ProviderScope( overrides: [ usersControllerProvider.overrideWith(() => fakeUsers), + accountsControllerProvider.overrideWith(() => fakeAccounts), activeUserControllerProvider.overrideWith(() => fakeActive), ], child: MaterialApp( @@ -75,6 +117,14 @@ Widget _buildOnboarding({ ); } +/// Заполняет имя на шаге 1 и переходит на шаг создания счёта. +Future _advanceToAccountStep(WidgetTester tester, String name) async { + await tester.enterText(find.byType(TextField), name); + await tester.pump(); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); +} + // ─── Tests ──────────────────────────────────────────────────────────────────── void main() { @@ -85,202 +135,145 @@ void main() { group('OnboardingScreen', () { late FakeUsersController fakeUsers; + late FakeAccountsController fakeAccounts; late FakeActiveUserController fakeActive; setUp(() { fakeUsers = FakeUsersController(); + fakeAccounts = FakeAccountsController(); fakeActive = FakeActiveUserController(); }); - // ── Рендеринг ────────────────────────────────────────────────────────── + Widget build() => _buildOnboarding( + fakeUsers: fakeUsers, + fakeAccounts: fakeAccounts, + fakeActive: fakeActive, + ); - testWidgets('отображает заголовок, подзаголовок, поле имени и кнопку', + // ── Шаг 1: имя ─────────────────────────────────────────────────────────── + + testWidgets('шаг 1: отображает заголовок, поле имени и кнопку', (tester) async { - await tester.pumpWidget( - _buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive), - ); + await tester.pumpWidget(build()); await tester.pump(); - // Локализация EN: "Welcome" / "Tell us your name to get started." expect(find.text('Welcome'), findsOneWidget); expect(find.text('Tell us your name to get started.'), findsOneWidget); - expect(find.byType(TextField), findsOneWidget); + expect(find.text('Your name'), findsOneWidget); expect(find.text('Continue'), findsOneWidget); }); - testWidgets('TextField имеет лейбл и хинт', (tester) async { - await tester.pumpWidget( - _buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive), - ); + testWidgets('шаг 1: пустое имя — кнопка Continue задизейблена', + (tester) async { + await tester.pumpWidget(build()); await tester.pump(); - expect(find.text('Your name'), findsOneWidget); + final button = tester.widget(find.byType(FilledButton)); + expect(button.onPressed, isNull); }); - // ── Валидация пустого имени ───────────────────────────────────────────── - - testWidgets('нажатие Continue с пустым полем не вызывает createUser', + testWidgets('шаг 1: Continue не создаёт пользователя — только переход', (tester) async { - await tester.pumpWidget( - _buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive), - ); + await tester.pumpWidget(build()); await tester.pump(); - await tester.tap(find.text('Continue')); - await tester.pump(); + await _advanceToAccountStep(tester, 'Alice'); + // Перешли на шаг счёта; пользователь ещё НЕ создан (записей в БД нет). + expect(find.text('Your first account'), findsOneWidget); expect(fakeUsers.createdNames, isEmpty); expect(fakeActive.activatedUsers, isEmpty); }); - testWidgets('нажатие Continue с пробелами не вызывает createUser', + // ── Шаг 2: создание первого счёта ──────────────────────────────────────── + + testWidgets('шаг 2: назад возвращает на шаг имени', (tester) async { + await tester.pumpWidget(build()); + await tester.pump(); + await _advanceToAccountStep(tester, 'Alice'); + + await tester.tap(find.byIcon(Icons.arrow_back)); + await tester.pumpAndSettle(); + + expect(find.text('Welcome'), findsOneWidget); + // Имя сохранилось. + expect(find.text('Alice'), findsOneWidget); + }); + + testWidgets('шаг 2: пустое имя счёта — кнопка задизейблена', (tester) async { - await tester.pumpWidget( - _buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive), - ); - await tester.pump(); - - await tester.enterText(find.byType(TextField), ' '); - await tester.tap(find.text('Continue')); + await tester.pumpWidget(build()); await tester.pump(); + await _advanceToAccountStep(tester, 'Alice'); + final button = tester.widget(find.byType(FilledButton)); + expect(button.onPressed, isNull); expect(fakeUsers.createdNames, isEmpty); }); - // ── Happy path ───────────────────────────────────────────────────────── - - testWidgets('валидное имя: createUser вызывается с trim-значением', + testWidgets( + 'шаг 2: сабмит создаёт пользователя, счёт (умолчательный) и активирует', (tester) async { - await tester.pumpWidget( - _buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive), - ); + await tester.pumpWidget(build()); await tester.pump(); + await _advanceToAccountStep(tester, ' Alice '); - await tester.enterText(find.byType(TextField), ' Alice '); + await tester.enterText(find.byType(TextField).first, ' Кошелёк '); + await tester.pump(); await tester.tap(find.text('Continue')); - await tester.pumpAndSettle(); + // Не pumpAndSettle: на успехе _submitting остаётся true (в реальном + // приложении редирект уводит с экрана), а бесконечный спиннер не даёт + // pumpAndSettle завершиться. + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 10)); + } + // Пользователь создан с trim-именем. expect(fakeUsers.createdNames, ['Alice']); + + // Счёт создан для этого пользователя. + expect(fakeAccounts.created, hasLength(1)); + expect(fakeAccounts.created.single.name, 'Кошелёк'); + expect(fakeAccounts.created.single.userId, 'test-uid'); + + // Счёт помечен умолчательным. + expect(fakeAccounts.setDefaultCalls, hasLength(1)); + expect(fakeAccounts.setDefaultCalls.single, ('acc-1', 'test-uid')); + + // Пользователь активирован. + expect(fakeActive.activatedUsers, hasLength(1)); + expect(fakeActive.activatedUsers.single.id, 'test-uid'); }); - testWidgets( - 'после createUser вызывается setActiveUser с возвращённым пользователем', - (tester) async { - await tester.pumpWidget( - _buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive), - ); - await tester.pump(); + // ── Состояние загрузки на финальном сабмите ────────────────────────────── - await tester.enterText(find.byType(TextField), 'Bob'); + testWidgets('во время сабмита кнопка показывает спиннер и задизейблена', + (tester) async { + final completer = Completer(); + fakeUsers.freezeNextCreate(completer); + + await tester.pumpWidget(build()); + await tester.pump(); + await _advanceToAccountStep(tester, 'Alice'); + + await tester.enterText(find.byType(TextField).first, 'Кошелёк'); + await tester.pump(); await tester.tap(find.text('Continue')); - await tester.pumpAndSettle(); + await tester.pump(); // тап + setState(_submitting = true) + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + final button = tester.widget(find.byType(FilledButton)); + expect(button.onPressed, isNull); + + completer.complete( + User(id: 'test-uid', name: 'Alice', createdAt: DateTime(2024, 1, 1)), + ); + // Спиннер остаётся (редиректа в тесте нет) — pumpAndSettle не завершится. + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 10)); + } expect(fakeActive.activatedUsers, hasLength(1)); - expect(fakeActive.activatedUsers.first.name, 'Bob'); - }); - - // ── Отправка через клавиатуру ────────────────────────────────────────── - - testWidgets('TextInputAction.done также запускает submit', (tester) async { - await tester.pumpWidget( - _buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive), - ); - await tester.pump(); - - await tester.enterText(find.byType(TextField), 'Carol'); - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pumpAndSettle(); - - expect(fakeUsers.createdNames, ['Carol']); - expect(fakeActive.activatedUsers, isNotEmpty); - }); - - // ── Состояние загрузки ───────────────────────────────────────────────── - - testWidgets( - 'во время отправки кнопка показывает CircularProgressIndicator', - (tester) async { - final completer = Completer(); - fakeUsers.freezeNextCreate(completer); - - await tester.pumpWidget( - _buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive), - ); - await tester.pump(); - - await tester.enterText(find.byType(TextField), 'Dave'); - await tester.tap(find.text('Continue')); - await tester.pump(); // Обрабатываем тап + setState(_submitting=true) - - // Пока awaiting: должен быть спиннер, кнопка «Continue» не видна. - expect(find.byType(CircularProgressIndicator), findsOneWidget); - expect(find.text('Continue'), findsNothing); - - // Разблокируем async-операцию. - completer.complete( - User(id: 'u-dave', name: 'Dave', createdAt: DateTime(2024, 1, 1)), - ); - await tester.pumpAndSettle(); - - // После завершения: спиннер исчез, кнопка снова видна. - expect(find.byType(CircularProgressIndicator), findsNothing); - expect(find.text('Continue'), findsOneWidget); - }); - - testWidgets('во время отправки кнопка задизейблена (onPressed == null)', - (tester) async { - final completer = Completer(); - fakeUsers.freezeNextCreate(completer); - - await tester.pumpWidget( - _buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive), - ); - await tester.pump(); - - await tester.enterText(find.byType(TextField), 'Eve'); - await tester.tap(find.text('Continue')); - await tester.pump(); - - final button = tester.widget(find.byType(FilledButton)); - expect(button.onPressed, isNull, - reason: 'Кнопка должна быть disabled во время отправки'); - - completer.complete( - User(id: 'u-eve', name: 'Eve', createdAt: DateTime(2024, 1, 1)), - ); - await tester.pumpAndSettle(); - - final buttonAfter = - tester.widget(find.byType(FilledButton)); - expect(buttonAfter.onPressed, isNotNull, - reason: 'После завершения кнопка снова активна'); - }); - - testWidgets('повторный тап во время отправки не создаёт второго пользователя', - (tester) async { - final completer = Completer(); - fakeUsers.freezeNextCreate(completer); - - await tester.pumpWidget( - _buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive), - ); - await tester.pump(); - - await tester.enterText(find.byType(TextField), 'Frank'); - await tester.tap(find.text('Continue')); - await tester.pump(); // Первый тап, _submitting = true - - // Пробуем тапнуть снова (кнопка задизейблена, но try programmatic). - await tester.tap(find.byType(FilledButton), warnIfMissed: false); - await tester.pump(); - - // createUser должен был быть вызван только один раз. - expect(fakeUsers.createdNames, hasLength(1)); - - completer.complete( - User(id: 'u-frank', name: 'Frank', createdAt: DateTime(2024, 1, 1)), - ); - await tester.pumpAndSettle(); }); }); } diff --git a/test/features/user/user_seeder_test.dart b/test/features/user/user_seeder_test.dart new file mode 100644 index 0000000..f739ae3 --- /dev/null +++ b/test/features/user/user_seeder_test.dart @@ -0,0 +1,157 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:new_budget/src/core/database/converters/enum_converters.dart'; +import 'package:new_budget/src/features/accounts/domain/entities/account.dart'; +import 'package:new_budget/src/features/accounts/domain/repositories/account_repository.dart'; +import 'package:new_budget/src/features/categories/domain/entities/category.dart'; +import 'package:new_budget/src/features/categories/domain/repositories/category_repository.dart'; +import 'package:new_budget/src/features/transactions/domain/repositories/transaction_repository.dart'; +import 'package:new_budget/src/features/user/application/user_seeder.dart'; + +/// `seedForNewUser` засевает только категории. Счета больше не создаются +/// автоматически — первый счёт пользователь создаёт сам на втором шаге +/// онбординга (и там же помечает его умолчательным). + +const _userId = 'u1'; +final _now = DateTime(2026, 6, 1); + +class FakeAccountRepository implements AccountRepository { + final List created = []; + final List<(String? id, String userId)> setDefaultCalls = []; + + @override + Future create({ + required String userId, + required String name, + required AccountType type, + required String currency, + int initialBalance = 0, + int? iconCode, + int? colorValue, + }) async { + final account = Account( + id: 'acc-${created.length}', + userId: userId, + name: name, + type: type, + currency: currency, + initialBalance: initialBalance, + iconCode: iconCode, + colorValue: colorValue, + archived: false, + createdAt: _now, + ); + created.add(account); + return account; + } + + @override + Future setDefault(String? id, String userId) async { + setDefaultCalls.add((id, userId)); + } + + @override + Stream> watchByUser(String userId) => + throw UnimplementedError(); + + @override + Future findById(String id) => throw UnimplementedError(); + + @override + Future update(Account account) => throw UnimplementedError(); + + @override + Future archive(String id) => throw UnimplementedError(); + + @override + Stream watchDefault(String userId) => throw UnimplementedError(); + + @override + Stream watchBalance(String accountId) => throw UnimplementedError(); +} + +class FakeCategoryRepository implements CategoryRepository { + final List created = []; + + @override + Future create({ + required String userId, + required String name, + required CategoryType type, + int? iconCode, + int? colorValue, + String? parentId, + }) async { + final category = Category( + id: 'cat-${created.length}', + userId: userId, + name: name, + type: type, + iconCode: iconCode, + colorValue: colorValue, + parentId: parentId, + archived: false, + ); + created.add(category); + return category; + } + + @override + Stream> watchByUser(String userId) => + throw UnimplementedError(); + + @override + Stream> watchByType(String userId, CategoryType type) => + throw UnimplementedError(); + + @override + Future findById(String id) => throw UnimplementedError(); + + @override + Future update(Category category) => throw UnimplementedError(); + + @override + Future archive(String id) => throw UnimplementedError(); +} + +/// Демо-транзакции при `seedForNewUser` не создаются — любой вызов = ошибка. +class _UnusedTransactionRepository implements TransactionRepository { + @override + dynamic noSuchMethod(Invocation invocation) => + throw StateError('seedForNewUser не должен трогать транзакции'); +} + +void main() { + late FakeAccountRepository accountRepo; + late FakeCategoryRepository categoryRepo; + late UserSeeder seeder; + + setUp(() { + accountRepo = FakeAccountRepository(); + categoryRepo = FakeCategoryRepository(); + seeder = UserSeeder( + accountRepo: accountRepo, + categoryRepo: categoryRepo, + txRepo: _UnusedTransactionRepository(), + ); + }); + + test('seedForNewUser не создаёт счета и не трогает умолчательный флаг', + () async { + await seeder.seedForNewUser(_userId); + + expect(accountRepo.created, isEmpty); + expect(accountRepo.setDefaultCalls, isEmpty); + }); + + test('seedForNewUser сидит категории обоих типов, но не транзакции', () async { + await seeder.seedForNewUser(_userId); + + // Базовые категории обоих типов; счетов и транзакций нет. + expect(categoryRepo.created, isNotEmpty); + expect( + categoryRepo.created.map((c) => c.type).toSet(), + {CategoryType.expense, CategoryType.income}, + ); + // Транзакций нет — _UnusedTransactionRepository бросил бы StateError. + }); +}