Add per-app rules, transfer pairing, self-merchant flag, two-step onboarding

Notification parsing:
- Per-app parse rules (parse_rules.packageName; getEnabledForApp, NULL = legacy global)
- Transfer pairing: transfer_pair_matcher + transfer_pairing_blocklist table/dao/repo
- source_apps.selfMerchant flag (Ozon inbox rework: default account picker, suppress
  AI category prefill + rule suggestion)
- raw_messages.diagnostics dump captured under diagnostic-mode toggle
- Inbox card / settings / log UI reworks

Onboarding:
- Two-step flow (name -> first account); UserSeeder seeds categories only, no accounts

Schema bumped to v11; drop obsolete migration + mixed-merchant tests, add new coverage.
Add ios/ platform folder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 22:49:04 +03:00
co-authored by Claude Opus 4.8
parent 1ac68c0eb1
commit da65bf6f8e
123 changed files with 5943 additions and 1246 deletions
+4 -1
View File
@@ -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)"
]
}
}
+26 -2
View File
@@ -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/
+1 -1
View File
@@ -15,7 +15,7 @@ migration:
- platform: root
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: android
- platform: ios
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
+39 -22
View File
@@ -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).
+1 -1
View File
@@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="new_budget"
android:label="Kitty finances"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -27,11 +27,25 @@ class NotificationIngestService : NotificationListenerService() {
val extras = notification.extras ?: return
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()
// big-text полнее обычного text — берём его при наличии.
val body = (extras.getCharSequence(Notification.EXTRA_BIG_TEXT)
val extrasBody = (extras.getCharSequence(Notification.EXTRA_BIG_TEXT)
?: extras.getCharSequence(Notification.EXTRA_TEXT))
?.toString()
?.trim()
.orEmpty()
// ВТБ прячет текст операции: в EXTRA_TEXT кладёт заглушку
// «Конфиденциальная информация в уведомлении скрыта» (реальный контент
// рисуется в custom RemoteViews и в extras не попадает), а полный текст
// («Списание 2000р Счет*3891 …») доступен только в tickerText. Второго
// поста с текстом, как у Т-Банка, ВТБ не шлёт — поэтому при пустом body
// или заглушке падаем обратно на ticker.
val ticker = notification.tickerText?.toString()?.trim().orEmpty()
val body = if ((extrasBody.isEmpty() || isHiddenContentStub(extrasBody)) &&
ticker.isNotEmpty()
) {
ticker
} else {
extrasBody
}
val diagnosticMode = store.isDiagnosticMode()
@@ -51,6 +65,12 @@ class NotificationIngestService : NotificationListenerService() {
LiveSink.tick()
}
/** Заглушка «содержимое скрыто» вместо текста операции (ВТБ и т.п.). */
private fun isHiddenContentStub(body: String): Boolean {
val lower = body.lowercase()
return lower.contains("конфиденциальн") && lower.contains("скрыт")
}
/**
* Человекочитаемый дамп уведомления для режима диагностики: `key`,
* `postTime`, `visibility`, `flags`, `tickerText` и все поля `extras`.
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="notif_listener_label">NewBudget — распознавание уведомлений</string>
<string name="notif_listener_label">Kitty finances — распознавание уведомлений</string>
</resources>
+5 -4
View File
@@ -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. Повторный вызов для того же пользователя: пиннить фактическое поведение
+2 -1
View File
@@ -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`-правило.
+34
View File
@@ -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
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+644
View File
@@ -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 = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* 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 = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
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 = "<group>";
};
/* 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 = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* 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 */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
+16
View File
@@ -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)
}
}
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

@@ -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.
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+75
View File
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Kitty finances</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>ru</string>
</array>
<key>CFBundleName</key>
<string>new_budget</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"
+6
View File
@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
+12
View File
@@ -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.
}
}
+20 -5
View File
@@ -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.",
+109 -19
View File
@@ -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:
+57 -10
View File
@@ -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';
+58 -10
View File
@@ -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 => 'Привязки счетов';
+20 -5
View File
@@ -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": "Пока нет привязок. Добавьте, чтобы связать карту со счётом.",
+1 -1
View File
@@ -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(),
+4 -2
View File
@@ -59,9 +59,11 @@ GoRouter appRouter(Ref ref) {
),
GoRoute(
path: AppRoutes.transactionNew,
pageBuilder: (context, state) => _slideUpPage<void>(
pageBuilder: (context, state) => _slideUpPage<String?>(
state,
const TransactionFormScreen(),
TransactionFormScreen(
prefill: state.extra as TransactionFormPrefill?,
),
),
),
GoRoute(
+3 -62
View File
@@ -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() {
@@ -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<SpendingObligation> build() => const {};
SpendingObligation? build() => null;
void toggle(SpendingObligation value) {
final next = Set<SpendingObligation>.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<SpendingImpulse?, int> habitSumByImpulse(Ref ref, String userId) {
return map;
}
/// Суммы расходов по обязательности (включая ключ `null` = «без оценки»).
/// Суммы расходов по обязательности (включая ключ `null` = «без оценки»)
/// внутри выборки активного импульс-фильтра: вторая строка каскада
/// показывает разбивку выбранного импульс-сегмента.
@riverpod
Map<SpendingObligation?, int> habitSumByObligation(Ref ref, String userId) {
final txs = ref.watch(habitMonthTransactionsProvider(userId));
final impulse = ref.watch(habitImpulseFilterProvider);
final map = <SpendingObligation?, int>{};
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<SpendingObligation?, int> habitSumByObligation(Ref ref, String userId) {
List<Transaction> 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();
}
@@ -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<int>(0, (s, v) => s + v);
final totalObligation =
obligationSums.values.fold<int>(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,25 +255,39 @@ class _FilterBlock extends ConsumerWidget {
const SizedBox(height: 16),
_ScaleCaption(l10n.habitScaleObligationCaps),
const SizedBox(height: 6),
Row(
AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: Row(
children: [
for (final o in SpendingObligation.values) ...[
Expanded(
child: _ObligationChip(
title: l10n.habitFilterAll,
sumMinor: totalObligation,
selected: obligationFilter == null,
onTap: () => ref
.read(habitObligationFilterProvider.notifier)
.select(null),
),
),
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.contains(o),
selected: obligationFilter == o,
onTap: () => ref
.read(habitObligationFilterProvider.notifier)
.toggle(o),
.select(obligationFilter == o ? null : o),
),
),
if (o != SpendingObligation.values.last)
const SizedBox(width: 8),
],
],
),
),
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,6 +419,7 @@ class _ObligationChip extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (dotColor != null) ...[
Container(
width: 8,
height: 8,
@@ -399,6 +427,7 @@ class _ObligationChip extends StatelessWidget {
BoxDecoration(color: dotColor, shape: BoxShape.circle),
),
const SizedBox(width: 6),
],
Flexible(
child: Text(
title,
@@ -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<int> inboxCount(Ref ref, String userId) =>
Stream<List<RawMessage>> parsingLog(Ref ref, String userId) =>
ref.watch(rawMessagesRepositoryProvider).watchAll(userId);
/// Частые категории подтверждённых транзакций этого приложения — чипы
/// быстрого выбора на карточке (источники с selfMerchant: Ozon и т.п.).
@riverpod
Stream<List<String>> topConfirmCategories(
Ref ref,
String userId,
String packageName,
TransactionType type,
) =>
ref
.watch(rawMessagesRepositoryProvider)
.watchTopCategoryIds(userId, packageName, type);
/// Вторая половинка склеенной пары — merged-карточка показывает её
/// приложение-источник.
@riverpod
Future<RawMessage?> pairedRawMessage(Ref ref, String id) =>
ref.watch(rawMessagesRepositoryProvider).findById(id);
/// Отображаемое имя приложения-источника (fallback — packageName).
@riverpod
Future<String> 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<void> 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<void> 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<void> ignore(RawMessage message) async {
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<void> 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<void> 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(rawMessagesRepositoryProvider)
.updateStatus(message.id, RawMessageStatus.ignored);
.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<void> 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<void> 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<void> 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<void> _maybeBind(
@@ -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] не
/// протухал до перезапуска. Подписка на стрим требует прямого слушателя
@@ -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<void> _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, шаги 38) для AI-draft.
///
/// [selfMerchant] — источник помечен «мерчант — само приложение» (Ozon):
/// AI-подсказка категории глушится, merchant→category правило не
/// предлагается — категорию пользователь выбирает вручную на карточке.
Future<void> _runPipeline(
String userId,
RawMessage msg,
ParseDraft draft0,
ParsingSettings settings, {
List<Category>? 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<void> 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 = <String>{};
// 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<void> 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<bool> _isBlocked(String userId, String idA, String idB) =>
_ref
.read(transferPairingBlocklistRepositoryProvider)
.contains(userId, pairSignature(idA, idB));
/// Склейка двух непримененных половинок: primary = transferOut (его счёт —
/// источник) уходит в Inbox merged-карточкой, secondary скрывается как
/// `paired`. Свой draftJson secondary сохраняет — он нужен для расклейки.
Future<void> _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<bool> _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});
@@ -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<void> setTransferPairingEnabled(bool value) async {
await _set(_kTransferPairing, '$value');
final current = state.value ?? _defaultSettings;
state = AsyncData(current.copyWith(transferPairingEnabled: value));
}
Future<void> setDiagnosticMode(bool value) async {
await _set(_kDiagnosticMode, '$value');
final current = state.value ?? _defaultSettings;
@@ -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<List<RawMessage>> pendingMessages(Ref ref, String userId) =>
ref.watch(rawMessagesRepositoryProvider).watchPending(userId);
/// ParsingWorker (§5): foreground-драйвер pipeline. Слушает
/// `raw_messages.pending` и прогоняет каждое сообщение через [ParsingPipeline].
/// Отвечает только за «когда запускать» (триггер/дедуп/ретраи); сама обработка
@@ -21,7 +18,14 @@ Stream<List<RawMessage>> 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<String> _inFlight = {};
@@ -30,13 +34,21 @@ 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;
// Первая эмиссия Drift-стрима — текущий снапшот: бэклог `pending`,
// накопившийся до старта приложения, дренится сразу.
final pendingSub = ref
.read(rawMessagesRepositoryProvider)
.watchPending(userId)
.listen((list) {
if (list.isEmpty) return;
// Фича выключена: pipeline всё равно no-op'нул бы каждое сообщение —
// не прогоняем растущий pending заново на каждую новую вставку.
// Обратное включение тумблера запускает явный drain (см. enabledSub).
@@ -45,10 +57,8 @@ class ParsingWorker extends _$ParsingWorker {
return;
}
_drain(userId, list);
},
fireImmediately: true,
);
ref.onDispose(sub.close);
});
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<RawMessage> 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<void> _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` — явный триггер при обратном включении
@@ -19,18 +19,21 @@ Future<ParseRule?> parseRuleById(Ref ref, String id) =>
ref.watch(parseRulesRepositoryProvider).findById(id);
/// Live-превью (§9.3): сообщения за 30 дней, совпадающие с паттерном.
/// [packageName] скоупит превью до приложения правила (null — без фильтра).
@riverpod
Future<List<RawMessage>> 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<ParseRule> 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,
@@ -38,6 +38,9 @@ class SourceAppsController extends _$SourceAppsController {
Future<void> setEnabled(String id, {required bool enabled}) =>
ref.read(sourceAppsRepositoryProvider).setEnabled(id, enabled: enabled);
Future<void> setSelfMerchant(String id, {required bool value}) =>
ref.read(sourceAppsRepositoryProvider).setSelfMerchant(id, value: value);
Future<void> delete(String id) =>
ref.read(sourceAppsRepositoryProvider).deleteById(id);
}
@@ -38,6 +38,17 @@ String buildSystemPrompt({required List<String> 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, если ничего не подходит.
@@ -23,11 +23,17 @@ class ParseRulesDao extends DatabaseAccessor<AppDatabase>
Future<List<ParseRulesTableData>> getByUser(String userId) =>
(select(parseRulesTable)..where((t) => t.userId.equals(userId))).get();
/// Только активные правила — для pipeline (rule_lookup).
Future<List<ParseRulesTableData>> getEnabledByUser(String userId) =>
/// Активные правила приложения [packageName] — для pipeline (rule_lookup).
/// NULL-строки (легаси без привязки) матчатся в любом приложении.
Future<List<ParseRulesTableData>> 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),
@@ -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<AppDatabase>
with _$RawMessagesDaoMixin {
RawMessagesDao(super.db);
@@ -43,6 +45,17 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)]))
.watch();
/// Поток половинок переводов, ждущих пару, — триггер sweep и перевзвод
/// таймера дедлайна в ParsingWorker. Порядок — по receivedAt, чтобы sweep
/// матчил детерминированно (старшая половинка первой).
Stream<List<RawMessagesTableData>> 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<List<RawMessagesTableData>> watchAll(String userId, {int limit = 200}) =>
@@ -65,6 +78,39 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
.map((rows) => rows.isEmpty ? 0 : (rows.first.data['c'] as int? ?? 0));
}
/// Частые категории подтверждённых транзакций из уведомлений приложения
/// [packageName] — для чипов быстрого выбора на карточке Inbox. Порядок:
/// по числу транзакций, при равенстве — по свежести.
Stream<List<String>> 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<String>(userId),
Variable<String>(packageName),
Variable<String>(const TransactionTypeConverter().toSql(type)),
Variable<int>(limit),
],
readsFrom: {rawMessagesTable, transactionsTable},
);
return query
.watch()
.map((rows) => rows.map((r) => r.data['cat'] as String).toList());
}
// ── Lookups ────────────────────────────────────────────────────────────────
Future<RawMessagesTableData?> findById(String id) =>
@@ -96,6 +142,22 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
..limit(1))
.getSingleOrNull();
/// Сообщения в [statuses] с `receivedAt ∈ [from, to]` — кандидаты в
/// контрпартнёры пары для sweep (фильтр по kind делается в Dart по draftJson).
Future<List<RawMessagesTableData>> findRecentByStatuses(
String userId,
DateTime from,
DateTime to,
List<RawMessageStatus> 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<void> insert(RawMessagesTableCompanion companion) =>
@@ -140,6 +202,58 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
),
);
/// Паркует transfer-половинку в ожидание пары: draft сохранён, AI повторно
/// не зовётся; sweep либо склеит её, либо релизнет в Inbox по [deadline].
Future<void> 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<void> 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<void> clearPairing(String id) =>
(update(rawMessagesTable)..where((t) => t.id.equals(id))).write(
const RawMessagesTableCompanion(
pairedWithId: Value(null),
pairDeadline: Value(null),
),
);
/// Откат доклейки: отвязка от транзакции (статус меняет вызывающий).
Future<void> unlinkTransaction(String id) =>
(update(rawMessagesTable)..where((t) => t.id.equals(id))).write(
const RawMessagesTableCompanion(transactionId: Value(null)),
);
/// Привязка к созданной транзакции (auto-apply).
Future<void> linkTransaction(String id, String transactionId) =>
(update(rawMessagesTable)..where((t) => t.id.equals(id))).write(
@@ -44,6 +44,10 @@ class SourceAppsDao extends DatabaseAccessor<AppDatabase>
(update(sourceAppsTable)..where((t) => t.id.equals(id)))
.write(SourceAppsTableCompanion(enabled: Value(enabled)));
Future<void> setSelfMerchant(String id, {required bool value}) =>
(update(sourceAppsTable)..where((t) => t.id.equals(id)))
.write(SourceAppsTableCompanion(selfMerchant: Value(value)));
Future<int> deleteById(String id) =>
(delete(sourceAppsTable)..where((t) => t.id.equals(id))).go();
}
@@ -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<AppDatabase>
with _$TransferPairingBlocklistDaoMixin {
TransferPairingBlocklistDao(super.db);
Future<void> insertEntry(TransferPairingBlocklistTableCompanion companion) =>
into(transferPairingBlocklistTable).insert(companion);
Future<bool> 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;
}
}
@@ -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())();
@@ -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
@@ -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
@@ -6,6 +6,7 @@ extension ParseRuleMapper on ParseRulesTableData {
ParseRule toDomain() => ParseRule(
id: id,
userId: userId,
packageName: packageName,
kind: kind,
matchMode: matchMode,
pattern: pattern,
@@ -22,6 +22,8 @@ extension RawMessageMapper on RawMessagesTableData {
confidenceMerchant: confidenceMerchant,
confidenceCategory: confidenceCategory,
transactionId: transactionId,
pairedWithId: pairedWithId,
pairDeadline: pairDeadline,
createdAt: createdAt,
);
}
@@ -9,6 +9,7 @@ extension SourceAppMapper on SourceAppsTableData {
packageName: packageName,
displayName: displayName,
enabled: enabled,
selfMerchant: selfMerchant,
createdAt: createdAt,
);
}
@@ -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<AutoApplyCheck> failedChecks;
/// Снапшот `AccountResolution.trusted` на момент парсинга — sweep решает
/// по нему допустимость доклейки (resolution к тому моменту уже недоступен).
final bool accountTrusted;
/// id второй половинки склеенного перевода. != null → merged-карточка
/// «Перевод между счетами» (или доклеенная половинка при [mergeUndo]).
final String? pairedRawMessageId;
/// Снапшот полей primary-draft, перезаписанных склейкой
/// (`{'type', 'categoryId'}`) — для восстановления при расклейке из Inbox.
final Map<String, dynamic>? preMerge;
/// Снапшот полей транзакции до доклейки (`{'txId', 'prevType',
/// 'prevAccountId', 'prevCategoryId', 'prevTransferToAccountId'}`) —
/// для отката доклеенного перевода из журнала.
final Map<String, dynamic>? mergeUndo;
}
String encodeDraftBundle(
ParseDraft draft,
RuleSuggestion? suggestion, {
Set<AutoApplyCheck> failedChecks = const {},
bool accountTrusted = false,
String? pairedRawMessageId,
Map<String, dynamic>? preMerge,
Map<String, dynamic>? mergeUndo,
}) {
return jsonEncode(<String, dynamic>{
'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<String, dynamic>?),
mergeUndo: (map['mergeUndo'] as Map<String, dynamic>?),
);
}
@@ -75,26 +75,6 @@ ParseRule? findSenderRule(
return matches.isEmpty ? null : matches.first;
}
/// Ищет активный маркер `mixedMerchant` для мерчанта (категория варьируется).
///
/// Наличие такого правила подавляет предложение merchant→category-правила в
/// Inbox (§9.1): pipeline не строит `RuleSuggestion`, и карточка показывает
/// «подтвердить разово» вместо кнопки «Создать правило».
ParseRule? findMixedMerchantRule(
List<ParseRule> 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<ParseRule> rules, {
@@ -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<T>(
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('|');
@@ -19,8 +19,13 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository {
.map((rows) => rows.map((r) => r.toDomain()).toList());
@override
Future<List<ParseRule>> getEnabledByUser(String userId) async =>
(await _dao.getEnabledByUser(userId)).map((r) => r.toDomain()).toList();
Future<List<ParseRule>> getEnabledForApp(
String userId,
String packageName,
) async =>
(await _dao.getEnabledForApp(userId, packageName))
.map((r) => r.toDomain())
.toList();
@override
Future<List<ParseRule>> getByUser(String userId) async =>
@@ -33,6 +38,7 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository {
@override
Future<ParseRule> 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<void> 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),
@@ -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<List<RawMessage>> watchAll(String userId) =>
_dao.watchAll(userId).map((rows) => rows.map((r) => r.toDomain()).toList());
@override
Stream<List<RawMessage>> watchWaitingPair(String userId) => _dao
.watchWaitingPair(userId)
.map((rows) => rows.map((r) => r.toDomain()).toList());
@override
Stream<int> watchInboxCount(String userId) => _dao.watchInboxCount(userId);
@override
Stream<List<String>> watchTopCategoryIds(
String userId, String packageName, TransactionType type) =>
_dao.watchTopCategoryIdsForPackage(userId, packageName, type);
@override
Future<RawMessage?> findById(String id) async =>
(await _dao.findById(id))?.toDomain();
@@ -56,6 +67,17 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
.map((r) => r.toDomain())
.toList();
@override
Future<List<RawMessage>> findRecentByStatuses(
String userId,
DateTime from,
DateTime to,
List<RawMessageStatus> statuses,
) async =>
(await _dao.findRecentByStatuses(userId, from, to, statuses))
.map((r) => r.toDomain())
.toList();
@override
Future<RawMessage> insertIncoming({
required String userId,
@@ -130,6 +152,39 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
Future<void> linkTransaction(String id, String transactionId) =>
_dao.linkTransaction(id, transactionId);
@override
Future<void> 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<void> setPairing(String id, String pairedWithId,
{RawMessageStatus? status}) =>
_dao.setPairing(id, pairedWithId, status: status);
@override
Future<void> clearPairing(String id) => _dao.clearPairing(id);
@override
Future<void> unlinkTransaction(String id) => _dao.unlinkTransaction(id);
@override
Future<void> incrementParseAttempts(String id) =>
_dao.incrementParseAttempts(id);
@@ -42,10 +42,18 @@ class SourceAppsRepositoryImpl implements SourceAppsRepository {
return row!.toDomain();
}
@override
Future<SourceApp?> findByPackageName(String userId, String packageName) async =>
(await _dao.findByPackageName(userId, packageName))?.toDomain();
@override
Future<void> setEnabled(String id, {required bool enabled}) =>
_dao.setEnabled(id, enabled: enabled);
@override
Future<void> setSelfMerchant(String id, {required bool value}) =>
_dao.setSelfMerchant(id, value: value);
@override
Future<void> deleteById(String id) => _dao.deleteById(id);
}
@@ -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<void> 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<bool> contains(String userId, String signature) =>
_dao.contains(userId, signature);
}
@@ -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,
@@ -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;
}
@@ -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;
}
@@ -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,
}
// ---------------------------------------------------------------------------
@@ -7,8 +7,9 @@ abstract interface class ParseRulesRepository {
/// Все правила пользователя — для экрана «Правила парсинга».
Stream<List<ParseRule>> watchByUser(String userId);
/// Только активные правила — для pipeline (rule_lookup).
Future<List<ParseRule>> getEnabledByUser(String userId);
/// Активные правила приложения [packageName] (+ легаси-строки без
/// packageName) — для pipeline (rule_lookup).
Future<List<ParseRule>> getEnabledForApp(String userId, String packageName);
Future<List<ParseRule>> getByUser(String userId);
@@ -17,6 +18,7 @@ abstract interface class ParseRulesRepository {
/// Создаёт правило (активно сразу, weight=1). Возвращает сохранённую сущность.
Future<ParseRule> create({
required String userId,
required String packageName,
required ParseRuleKind kind,
required MatchMode matchMode,
required String pattern,
@@ -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<List<RawMessage>> watchAll(String userId);
/// Половинки переводов, ждущие пару (`waitingPair`) — триггер sweep
/// и таймера дедлайна в ParsingWorker.
Stream<List<RawMessage>> watchWaitingPair(String userId);
/// Реактивный счётчик Inbox — для бэджа на Home.
Stream<int> watchInboxCount(String userId);
/// Частые категории подтверждённых транзакций из уведомлений приложения —
/// для чипов быстрого выбора на карточке Inbox (по частоте, затем свежести).
Stream<List<String>> watchTopCategoryIds(
String userId, String packageName, TransactionType type);
Future<RawMessage?> findById(String id);
/// Поиск дубля: совпадение [dedupHash] **и** `receivedAt` в пределах
@@ -33,6 +43,15 @@ abstract interface class RawMessagesRepository {
/// Сообщения за период (для live-превью правил).
Future<List<RawMessage>> recentByUser(String userId, DateTime since);
/// Сообщения в [statuses] с `receivedAt ∈ [from, to]` — кандидаты в
/// контрпартнёры пары для sweep.
Future<List<RawMessage>> findRecentByStatuses(
String userId,
DateTime from,
DateTime to,
List<RawMessageStatus> statuses,
);
/// Идемпотентная вставка нового уведомления. Если сообщение с тем же
/// dedup-хэшем уже есть **в пределах дедуп-окна по [receivedAt]** —
/// возвращает существующее, не создавая дубль. Тот же текст вне окна
@@ -67,6 +86,30 @@ abstract interface class RawMessagesRepository {
/// Привязка к созданной транзакции (status → applied).
Future<void> linkTransaction(String id, String transactionId);
/// Паркует transfer-половинку в ожидание пары (status → waitingPair,
/// draft сохранён, дедлайн взведён).
Future<void> holdForPairing({
required String id,
required String draftJson,
required DateTime deadline,
int? confidenceAmount,
int? confidenceAccount,
int? confidenceType,
int? confidenceMerchant,
int? confidenceCategory,
});
/// Связывает половинку с парой; [status] — опциональная смена статуса
/// (secondary → paired).
Future<void> setPairing(String id, String pairedWithId,
{RawMessageStatus? status});
/// Расклейка: снимает связь с парой.
Future<void> clearPairing(String id);
/// Откат доклейки: отвязка от транзакции (статус меняет вызывающий).
Future<void> unlinkTransaction(String id);
Future<void> incrementParseAttempts(String id);
/// Сброс сообщения на повторную обработку (status → pending, попытки → 0).
@@ -9,6 +9,10 @@ abstract interface class SourceAppsRepository {
/// Стрим, чтобы фильтр и native-синк реагировали на изменения allowlist.
Stream<Set<String>> watchEnabledPackages(String userId);
/// Приложение по packageName — снапшот для pipeline (allowlist + флаг
/// [SourceApp.selfMerchant]). null = приложение не добавлено.
Future<SourceApp?> findByPackageName(String userId, String packageName);
/// Добавляет приложение (включено по умолчанию). Возвращает сущность.
/// Если строка с таким packageName уже есть — возвращает существующую.
Future<SourceApp> add({
@@ -19,5 +23,8 @@ abstract interface class SourceAppsRepository {
Future<void> setEnabled(String id, {required bool enabled});
/// Флаг «мерчант — само приложение» (Ozon, маркетплейсы).
Future<void> setSelfMerchant(String id, {required bool value});
Future<void> deleteById(String id);
}
@@ -0,0 +1,10 @@
/// Blocklist расклеенных пар переводов.
///
/// v1: сигнатура — разовая, по конкретной паре сообщений
/// (`pairSignature(msgIdA, msgIdB)`), не вечный бан пары счетов. Расклейка
/// пишет сигнатуру, чтобы sweep не склеил ту же пару снова.
abstract interface class TransferPairingBlocklistRepository {
Future<void> add(String userId, String signature);
Future<bool> contains(String userId, String signature);
}
@@ -79,9 +79,8 @@ class InboxScreen extends ConsumerWidget {
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 8),
child: Row(
children: [
Expanded(
child: SizedBox(
width: double.infinity,
child: FilledButton(
style: FilledButton.styleFrom(backgroundColor: p.accent),
onPressed: () => ref
@@ -90,21 +89,6 @@ class InboxScreen extends ConsumerWidget {
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),
),
),
],
),
),
),
],
@@ -38,7 +38,8 @@ class _ParsingLogScreenState extends ConsumerState<ParsingLogScreen> {
_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,
};
}
@@ -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(
@@ -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<RuleEditorScreen> {
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<RuleEditorScreen> {
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<RuleEditorScreen> {
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<RuleEditorScreen> {
}
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<void> _save(String userId, ParseRule? existing) async {
final pattern = _patternCtrl.text.trim();
@@ -146,6 +161,7 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
} else {
context.pop(
RuleEditorResult(
packageName: _packageName!,
kind: _kind,
matchMode: _matchMode,
pattern: pattern,
@@ -184,6 +200,15 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
final accountName = _accountId == null
? null
: accounts.where((a) => a.id == _accountId).firstOrNull?.name;
final sourceApps = ref.watch(sourceAppsListProvider(userId)).value ??
const <SourceApp>[];
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<RuleEditorScreen> {
// Выбор вида — только при создании из списка правил. Из 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<String>(
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,10 +309,6 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
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),
@@ -289,7 +356,6 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
if (id != null) setState(() => _accountId = id);
},
),
],
const SizedBox(height: 12),
_AdvancedToggle(
expanded: _advanced,
@@ -326,6 +392,7 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
userId: userId,
pattern: _patternCtrl.text,
mode: _matchMode,
packageName: _packageName,
),
const SizedBox(height: 18),
FilledButton(
@@ -347,19 +414,22 @@ 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 ??
final matches = ref
.watch(ruleMatchPreviewProvider(userId, pattern, mode, packageName))
.value ??
const [];
return Column(
@@ -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<RulesListScreen> {
final accounts =
ref.watch(accountsStreamProvider(userId)).value ?? const <Account>[];
final accountById = {for (final a in accounts) a.id: a};
final sourceApps = ref.watch(sourceAppsListProvider(userId)).value ??
const <SourceApp>[];
final displayNameByPackage = {
for (final a in sourceApps) a.packageName: a.displayName,
};
// Секции по приложению-источнику: заголовок = displayName ?? packageName
// (легаси-правила без привязки — в секцию «Неизвестное приложение»).
final groups = <String, List<ParseRule>>{};
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<RulesListScreen> {
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],
: 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(rules[i].id)),
onTap: () => context
.push(AppRoutes.parsingRuleEdit(rule.id)),
onToggle: (enabled) => ref
.read(rulesControllerProvider.notifier)
.setEnabled(rules[i].id, enabled: enabled),
.setEnabled(rule.id, enabled: enabled),
),
],
],
],
),
),
],
@@ -132,6 +166,7 @@ class _RulesListScreenState extends ConsumerState<RulesListScreen> {
if (result == null) return;
await ref.read(rulesControllerProvider.notifier).create(
userId: userId,
packageName: result.packageName,
kind: result.kind,
matchMode: result.matchMode,
pattern: result.pattern,
@@ -338,7 +338,9 @@ 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: [
Row(
children: [
Icon(Icons.apps_outlined, size: 20, color: p.ink2),
const SizedBox(width: 12),
@@ -367,6 +369,37 @@ class _AddedAppTile extends ConsumerWidget {
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),
),
),
],
),
),
),
],
),
),
);
}
@@ -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,9 +53,16 @@ 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 → «подтвердить разово» без создания правила.
// suggestion == null → нет мерчанта (только сумма) ИЛИ источник
// с selfMerchant → «Подтвердить» без создания правила.
: bundle.suggestion != null
? _RecognizedBody(
message: message,
@@ -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,21 +247,26 @@ 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(
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: accountId,
accountId: acc,
categoryId: categoryId,
merchantCanonical: merchant,
pattern: draft.merchantRaw ?? merchant,
),
);
}
@@ -280,33 +281,65 @@ class _RecognizedBody extends ConsumerWidget {
final result = await context.push<RuleEditorResult?>(
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(
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,
merchantCanonical: result.merchantCanonical.isEmpty
? merchant
: result.merchantCanonical,
pattern: result.pattern,
matchMode: result.matchMode,
),
);
}
}
/// Выполняет действие контроллера и показывает SnackBar при ошибке —
/// InboxController делает rethrow, и без обработки тап выглядит как no-op.
Future<void> _runReporting(
BuildContext context,
Future<void> 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<String?> _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 <String>[];
final chipCategories = topCategoryIds
.map((id) => widget.categoryById[id])
.whereType<Category>()
.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<void> _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<void> _openFullForm() async {
final message = widget.message;
final draft = widget.bundle.draft;
final txId = await context.push<String?>(
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<void> _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<void> _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,6 +995,8 @@ class _CreateRuleButton extends StatelessWidget {
child: InkWell(
borderRadius: const BorderRadius.horizontal(left: Radius.circular(12)),
onTap: onTap,
child: Opacity(
opacity: onTap == null ? 0.55 : 1,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Text(
@@ -638,12 +1012,13 @@ class _CreateRuleButton extends StatelessWidget {
),
),
),
),
Container(width: 1, height: 28, color: Colors.white24),
InkWell(
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),
@@ -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 =

Some files were not shown because too many files have changed in this diff Show More