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 -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 -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(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 *.keystore
# iOS / macOS # 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.framework
**/ios/Flutter/Flutter.podspec **/ios/Flutter/Flutter.podspec
**/ios/**/*.xcworkspace/ **/ios/Flutter/Generated.xcconfig
**/macos/**/*.xcworkspace/ **/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/ Pods/
.symlinks/ .symlinks/
+1 -1
View File
@@ -15,7 +15,7 @@ migration:
- platform: root - platform: root
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: android - platform: ios
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_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/app_colors.dart # Palette extension (paper/ink/line/accent/positive/negative)
theme/theme_mode_controller.dart # @Riverpod(keepAlive) ThemeMode — in-memory for now theme/theme_mode_controller.dart # @Riverpod(keepAlive) ThemeMode — in-memory for now
core/ 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/tables/ # users / app_preferences / settings / accounts / categories / transactions
database/daos/ # *_dao.dart with .watch*() methods database/daos/ # *_dao.dart with .watch*() methods
database/converters/enum_converters.dart # TypeConverter + re-exports all enums (UI imports enums from here) 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). notification_parsing/ # SMS/notification → transaction parsing (rules + AI/DeepSeek).
# domain/data/application/presentation; Drift tables + DAOs; # domain/data/application/presentation; Drift tables + DAOs;
# data/parser/ (dedup, confidence, decision_gate, ai_parser), # 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 # Screens: inbox, rules_list, rule_editor, parsing_settings, ai_consent, parsing_log
profile/ # theme switcher screen profile/ # theme switcher screen
shared/ shared/
@@ -104,10 +108,17 @@ Every domain table has a `userId` FK → `users`.
| `categories` | id, userId, name, type(enum), iconCode, colorValue, parentId(nullable), archived | | `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 | | `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`, **Notification-parsing tables** (in `features/notification_parsing/data/drift/`): `raw_messages`
`parse_rules` (+`txType` — transaction type pinned at rule creation, checked by the gate), (+`diagnostics` — full notification-extras dump, captured only while the diagnostic-mode toggle
`rule_candidates`, `account_bindings` (+`isDefault` per-app default binding), in parsing settings is on), `parse_rules` (+`txType` — transaction type pinned at rule creation,
`source_apps` (allowlist of monitored apps), `transfer_pairing_blocklist`. Their enums live 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 in `notification_parsing/domain/enums.dart`; converters in `.../data/drift/converters.dart` (both
imported by `app_database.dart`). 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 Active profile is stored in `app_preferences` (key `active_user_id`), read via
`activeUserControllerProvider`. `appRouter` has a `redirect` callback + `refreshListenable` `activeUserControllerProvider`. `appRouter` has a `redirect` callback + `refreshListenable`
on this provider: until a user exists, all routes redirect to `/onboarding`; after on this provider: until a user exists, all routes redirect to `/onboarding`.
`usersController.createUser`, the redirect clears and `UserSeeder.seedForNewUser(userId)` `onboarding_screen.dart` is a **two-step flow** (name → first account) that does no DB
seeds default accounts and categories. Demo transactions are **not** auto-seeded — they're writes until the final submit: it calls `usersController.createUser` (which runs
added only via the manual "seed demo" button in `profile_screen` (temporary; remove once `UserSeeder.seedForNewUser(userId)` — now seeds **default categories only**, no accounts),
add-transaction UX is finished). 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: Icon/color helpers that used to live in `_mock_data.dart` now live with their features:
`features/categories/presentation/widgets/category_icon.dart` (`iconForCategory`, `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 - Access the container inside a widget test via
`ProviderScope.containerOf(tester.element(find.byType(MyScreen)))` to manipulate `ProviderScope.containerOf(tester.element(find.byType(MyScreen)))` to manipulate
notifier state after `pumpWidget`. notifier state after `pumpWidget`.
- **`ParsingWorker` won't drain on `container.read` alone.** Its input - **`ParsingWorker` inputs are direct repository stream subscriptions** (no intermediate
`pendingMessagesProvider` is autoDispose and only subscribes to the Drift stream when it autoDispose stream providers): `container.read(parsingWorkerProvider(userId))` alone is
has a *direct* listener (in-app that's `HomeScreen`). In a bare `ProviderContainer` add enough to drain in tests. Rationale: an internal `ref.listen` of a *paused* provider (a
`container.listen(pendingMessagesProvider(userId), (_, _) {}, fireImmediately: true)` worker with no listeners of its own, i.e. bare-container tests) does not activate its
alongside reading the worker, or pending messages stay stuck at `pending`. autoDispose dependencies — Riverpod 3 pause semantics. Don't reintroduce stream
- **Integration tests hitting real OpenRouter** live in providers as worker inputs.
- **Integration tests hitting the real DeepSeek API** live in
`test/features/notification_parsing/integration/`, tagged `@Tags(['integration'])`. Run `test/features/notification_parsing/integration/`, tagged `@Tags(['integration'])`. Run
with `flutter test ... --tags integration --dart-define=OPENROUTER_API_KEY=sk-or-...` with `flutter test ... --tags integration --dart-define=DEEPSEEK_API_KEY=sk-...`
(key never hardcoded; tests `skip:` when it's absent so default `flutter test` stays (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` green/offline). Override `aiKeyStoreProvider` with a fake key store + `isOnlineProvider`
with `Stream.value(true)` (connectivity_plus has no binding under `flutter test`); assert with `Stream.value(true)` (connectivity_plus has no binding under `flutter test`); assert
the terminal `RawMessageStatus` and decode `draftJson` via `decodeDraftBundle`. Drafts for 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` (`build() => ThemeMode.dark`) and `app.dart` reads it, not settings. The `settings.themeMode`
column exists but is unused. Mirror `AppLocaleController` (locale is already persisted). 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 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 solid. `seedForNewUser` now seeds **only categories** (accounts are created by the user in
+ categories); what remains is the manual path — `seedDemoTransactionsForUser` / onboarding); what remains to remove is the manual demo path — `seedDemoTransactionsForUser`
`_seedDemoTransactions` + the "seed demo" button in `profile_screen`. / `_seedDemoTransactions` / `_seedAccounts` + the "seed demo" button in `profile_screen`.
4. `shared/formatters/` — empty; add intl money + date formatters; migrate `MoneyText` + day grouping. 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). 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"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application <application
android:label="new_budget" android:label="Kitty finances"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity
@@ -27,11 +27,25 @@ class NotificationIngestService : NotificationListenerService() {
val extras = notification.extras ?: return val extras = notification.extras ?: return
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()
// big-text полнее обычного text — берём его при наличии. // big-text полнее обычного text — берём его при наличии.
val body = (extras.getCharSequence(Notification.EXTRA_BIG_TEXT) val extrasBody = (extras.getCharSequence(Notification.EXTRA_BIG_TEXT)
?: extras.getCharSequence(Notification.EXTRA_TEXT)) ?: extras.getCharSequence(Notification.EXTRA_TEXT))
?.toString() ?.toString()
?.trim() ?.trim()
.orEmpty() .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() val diagnosticMode = store.isDiagnosticMode()
@@ -51,6 +65,12 @@ class NotificationIngestService : NotificationListenerService() {
LiveSink.tick() LiveSink.tick()
} }
/** Заглушка «содержимое скрыто» вместо текста операции (ВТБ и т.п.). */
private fun isHiddenContentStub(body: String): Boolean {
val lower = body.lowercase()
return lower.contains("конфиденциальн") && lower.contains("скрыт")
}
/** /**
* Человекочитаемый дамп уведомления для режима диагностики: `key`, * Человекочитаемый дамп уведомления для режима диагностики: `key`,
* `postTime`, `visibility`, `flags`, `tickerText` и все поля `extras`. * `postTime`, `visibility`, `flags`, `tickerText` и все поля `extras`.
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="notif_listener_label">NewBudget — распознавание уведомлений</string> <string name="notif_listener_label">Kitty finances — распознавание уведомлений</string>
</resources> </resources>
+5 -4
View File
@@ -277,10 +277,11 @@ await verifier.migrateAndValidate(db, 8);
Сейчас сидер всюду фейкается; реальный код не исполняется ни одним тестом, при этом Сейчас сидер всюду фейкается; реальный код не исполняется ни одним тестом, при этом
от него зависит первый запуск приложения. от него зависит первый запуск приложения.
1. `seedForNewUser(userId)` → созданы дефолтные счета и категории (количества > 0; 1. `seedForNewUser(userId)` → созданы дефолтные **категории** (количество > 0; точные
точные наборы не пиннить — они будут меняться), все с правильным `userId`. наборы не пиннить), все с правильным `userId`. Счета НЕ создаются и `isDefault` не
2. Один счёт помечен `isDefault` (если это контракт сидера — проверить по коду). выставляется — первый счёт создаёт пользователь на втором шаге онбординга.
3. Демо-транзакции созданы и ссылаются на посеянные счета/категории (FK-цепочка цела). 2. `seedDemoTransactionsForUser(userId)` (ручной путь из профиля) — создаёт недостающие
счета/категории и демо-транзакции, ссылающиеся на них (FK-цепочка цела).
Тест оформить так, чтобы при удалении демо-сида (пункт 2 бэклога CLAUDE.md) Тест оформить так, чтобы при удалении демо-сида (пункт 2 бэклога CLAUDE.md)
достаточно было удалить один блок ассертов. достаточно было удалить один блок ассертов.
4. Повторный вызов для того же пользователя: пиннить фактическое поведение 4. Повторный вызов для того же пользователя: пиннить фактическое поведение
+2 -1
View File
@@ -147,7 +147,8 @@ unique `(userId, packageName)`. Присутствие строки = прило
`accountTrusted=false` (multi-binding) → `inbox`; нет счёта → `inbox`. `accountTrusted=false` (multi-binding) → `inbox`; нет счёта → `inbox`.
4. **Allowlist-тест** (`parsing_worker`): сообщение от пакета НЕ из включённых → `ignored`, AI не вызывается; 4. **Allowlist-тест** (`parsing_worker`): сообщение от пакета НЕ из включённых → `ignored`, AI не вызывается;
от включённого банка без карты, но с глобальным дефолтом + правилом категории → авто-применение на дефолт. от включённого банка без карты, но с глобальным дефолтом + правилом категории → авто-применение на дефолт.
(Помнить про `container.listen(pendingMessagesProvider(userId), …)` — см. CLAUDE.md.) (Устарело: `pendingMessagesProvider` удалён — воркер подписан на Drift-стримы репозитория
напрямую, в тестах достаточно `container.read(parsingWorkerProvider(userId))`.)
5. Существующий пакет `test/features/notification_parsing/` — зелёный. 5. Существующий пакет `test/features/notification_parsing/` — зелёный.
6. Ручная проверка (`flutter run`): добавить приложение из каталога → включить → задать привязку к счёту 6. Ручная проверка (`flutter run`): добавить приложение из каталога → включить → задать привязку к счёту
и дефолт; убедиться, что уведомление от не-включённого пакета игнорируется; создать `senderToAccount`-правило. и дефолт; убедиться, что уведомление от не-включённого пакета игнорируется; создать `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", "@@locale": "en",
"appTitle": "NewBudget", "appTitle": "Kitty finances",
"navHome": "Home", "navHome": "Home",
"navAnalytics": "Analytics", "navAnalytics": "Analytics",
@@ -84,6 +84,8 @@
"onboardingNameLabel": "Your name", "onboardingNameLabel": "Your name",
"onboardingNameHint": "How should we address you?", "onboardingNameHint": "How should we address you?",
"onboardingContinue": "Continue", "onboardingContinue": "Continue",
"onboardingAccountTitle": "Your first account",
"onboardingAccountSubtitle": "Add an account to track your money. You can add more later.",
"txNewTitle": "New transaction", "txNewTitle": "New transaction",
"txEditTitle": "Edit transaction", "txEditTitle": "Edit transaction",
@@ -208,6 +210,8 @@
"parsingEnableLabel": "Recognize notifications", "parsingEnableLabel": "Recognize notifications",
"parsingAutoApplyLabel": "Auto-add transactions", "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.", "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", "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.", "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}", "inboxWhyNotAuto": "Why not automatic: {reasons}",
@@ -247,6 +251,9 @@
"parsingStatusFailed": "Failed", "parsingStatusFailed": "Failed",
"parsingStatusPendingAi": "Waiting for AI", "parsingStatusPendingAi": "Waiting for AI",
"parsingStatusParsedPartial": "Partial", "parsingStatusParsedPartial": "Partial",
"parsingStatusWaitingPair": "Waiting for pair",
"parsingStatusPaired": "Merged into transfer",
"parsingUnmergeTransfer": "Split transfer",
"parsingLogOnline": "Online", "parsingLogOnline": "Online",
"parsingLogOffline": "Offline", "parsingLogOffline": "Offline",
@@ -294,12 +301,15 @@
"@inboxCreateRule": { "placeholders": { "merchant": { "type": "String" }, "category": { "type": "String" } } }, "@inboxCreateRule": { "placeholders": { "merchant": { "type": "String" }, "category": { "type": "String" } } },
"inboxCreateRuleNoCategory": "Create rule for “{merchant}”", "inboxCreateRuleNoCategory": "Create rule for “{merchant}”",
"@inboxCreateRuleNoCategory": { "placeholders": { "merchant": { "type": "String" } } }, "@inboxCreateRuleNoCategory": { "placeholders": { "merchant": { "type": "String" } } },
"inboxConfirmOnce": "Confirm once",
"inboxConfirm": "Confirm", "inboxConfirm": "Confirm",
"inboxMarkMixed": "Categories vary", "inboxCategoryRequiredHint": "Pick a category to confirm",
"inboxIgnore": "Ignore", "inboxIgnore": "Ignore",
"inboxApplyAll": "Apply all", "inboxApplyAll": "Apply all",
"inboxHideAll": "Hide all", "inboxTransferPairTitle": "Transfer between accounts",
"inboxTransferFrom": "From account",
"inboxTransferTo": "To account",
"inboxTransferPickAccount": "Choose…",
"inboxUnpair": "Split",
"inboxUnrecognized": "Not recognized", "inboxUnrecognized": "Not recognized",
"inboxParseErrorTitle": "Recognition error", "inboxParseErrorTitle": "Recognition error",
"inboxRetry": "Try again", "inboxRetry": "Try again",
@@ -326,13 +336,16 @@
"ruleEditorModeContains": "Contains", "ruleEditorModeContains": "Contains",
"ruleEditorModeExact": "Exact", "ruleEditorModeExact": "Exact",
"ruleEditorModeRegex": "Regex", "ruleEditorModeRegex": "Regex",
"ruleKindMixedHint": "No category rule is suggested for this merchant: the category is picked manually for each transaction.",
"ruleEditorThen": "Then it is:", "ruleEditorThen": "Then it is:",
"ruleEditorMerchant": "Merchant", "ruleEditorMerchant": "Merchant",
"ruleEditorMerchantHint": "e.g. Wildberries", "ruleEditorMerchantHint": "e.g. Wildberries",
"ruleEditorCategory": "Category", "ruleEditorCategory": "Category",
"ruleEditorAccount": "Account", "ruleEditorAccount": "Account",
"ruleEditorAccountUnchanged": "— keep unchanged —", "ruleEditorAccountUnchanged": "— keep unchanged —",
"ruleEditorApp": "App",
"ruleEditorAppPick": "Select app",
"ruleEditorNoApps": "Add a source app first",
"rulesUnknownApp": "Unknown app",
"ruleEditorAdvanced": "Advanced", "ruleEditorAdvanced": "Advanced",
"ruleEditorPriority": "Priority", "ruleEditorPriority": "Priority",
"ruleEditorMatchesTitle": "Matches (last 30 days)", "ruleEditorMatchesTitle": "Matches (last 30 days)",
@@ -416,6 +429,8 @@
"sourceAppsSearchHint": "Search", "sourceAppsSearchHint": "Search",
"sourceAppsPickerEmpty": "No apps found", "sourceAppsPickerEmpty": "No apps found",
"sourceAppsAlreadyAdded": "Already added", "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", "appBindingsTitle": "Account bindings",
"appBindingsEmpty": "No bindings yet. Add one to map a card to an account.", "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. /// No description provided for @appTitle.
/// ///
/// In ru, this message translates to: /// In ru, this message translates to:
/// **'NewBudget'** /// **'Kitty finances'**
String get appTitle; String get appTitle;
/// No description provided for @navHome. /// No description provided for @navHome.
@@ -308,6 +308,18 @@ abstract class AppLocalizations {
/// **'Продолжить'** /// **'Продолжить'**
String get onboardingContinue; 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. /// No description provided for @txNewTitle.
/// ///
/// In ru, this message translates to: /// In ru, this message translates to:
@@ -920,6 +932,18 @@ abstract class AppLocalizations {
/// **'Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.'** /// **'Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.'**
String get parsingAutoApplyHint; 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. /// No description provided for @parsingDiagnosticModeLabel.
/// ///
/// In ru, this message translates to: /// In ru, this message translates to:
@@ -1136,6 +1160,24 @@ abstract class AppLocalizations {
/// **'Частично'** /// **'Частично'**
String get parsingStatusParsedPartial; 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. /// No description provided for @parsingLogOnline.
/// ///
/// In ru, this message translates to: /// In ru, this message translates to:
@@ -1382,23 +1424,17 @@ abstract class AppLocalizations {
/// **'Создать правило для «{merchant}»'** /// **'Создать правило для «{merchant}»'**
String inboxCreateRuleNoCategory(String merchant); String inboxCreateRuleNoCategory(String merchant);
/// No description provided for @inboxConfirmOnce.
///
/// In ru, this message translates to:
/// **'Подтвердить разово'**
String get inboxConfirmOnce;
/// No description provided for @inboxConfirm. /// No description provided for @inboxConfirm.
/// ///
/// In ru, this message translates to: /// In ru, this message translates to:
/// **'Подтвердить'** /// **'Подтвердить'**
String get inboxConfirm; String get inboxConfirm;
/// No description provided for @inboxMarkMixed. /// No description provided for @inboxCategoryRequiredHint.
/// ///
/// In ru, this message translates to: /// In ru, this message translates to:
/// **'Категории различаются'** /// **'Выберите категорию, чтобы подтвердить'**
String get inboxMarkMixed; String get inboxCategoryRequiredHint;
/// No description provided for @inboxIgnore. /// No description provided for @inboxIgnore.
/// ///
@@ -1412,11 +1448,35 @@ abstract class AppLocalizations {
/// **'Учесть все'** /// **'Учесть все'**
String get inboxApplyAll; String get inboxApplyAll;
/// No description provided for @inboxHideAll. /// No description provided for @inboxTransferPairTitle.
/// ///
/// In ru, this message translates to: /// 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. /// No description provided for @inboxUnrecognized.
/// ///
@@ -1556,12 +1616,6 @@ abstract class AppLocalizations {
/// **'Regex'** /// **'Regex'**
String get ruleEditorModeRegex; String get ruleEditorModeRegex;
/// No description provided for @ruleKindMixedHint.
///
/// In ru, this message translates to:
/// **'Для этого мерчанта правила-категории не предлагаются: категория выбирается вручную для каждой операции.'**
String get ruleKindMixedHint;
/// No description provided for @ruleEditorThen. /// No description provided for @ruleEditorThen.
/// ///
/// In ru, this message translates to: /// In ru, this message translates to:
@@ -1598,6 +1652,30 @@ abstract class AppLocalizations {
/// **'— не менять —'** /// **'— не менять —'**
String get ruleEditorAccountUnchanged; 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. /// No description provided for @ruleEditorAdvanced.
/// ///
/// In ru, this message translates to: /// In ru, this message translates to:
@@ -1946,6 +2024,18 @@ abstract class AppLocalizations {
/// **'Уже добавлено'** /// **'Уже добавлено'**
String get sourceAppsAlreadyAdded; 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. /// No description provided for @appBindingsTitle.
/// ///
/// In ru, this message translates to: /// In ru, this message translates to:
+57 -10
View File
@@ -9,7 +9,7 @@ class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale); AppLocalizationsEn([String locale = 'en']) : super(locale);
@override @override
String get appTitle => 'NewBudget'; String get appTitle => 'Kitty finances';
@override @override
String get navHome => 'Home'; String get navHome => 'Home';
@@ -142,6 +142,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get onboardingContinue => 'Continue'; 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 @override
String get txNewTitle => 'New transaction'; String get txNewTitle => 'New transaction';
@@ -475,6 +482,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get parsingAutoApplyHint => String get parsingAutoApplyHint =>
'Add without confirmation when a rule exists, the amount is found in the message text and the account is trusted.'; '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 @override
String get parsingDiagnosticModeLabel => 'Diagnostic mode'; String get parsingDiagnosticModeLabel => 'Diagnostic mode';
@@ -593,6 +607,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get parsingStatusParsedPartial => 'Partial'; 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 @override
String get parsingLogOnline => 'Online'; String get parsingLogOnline => 'Online';
@@ -724,14 +747,11 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Create rule for “$merchant'; return 'Create rule for “$merchant';
} }
@override
String get inboxConfirmOnce => 'Confirm once';
@override @override
String get inboxConfirm => 'Confirm'; String get inboxConfirm => 'Confirm';
@override @override
String get inboxMarkMixed => 'Categories vary'; String get inboxCategoryRequiredHint => 'Pick a category to confirm';
@override @override
String get inboxIgnore => 'Ignore'; String get inboxIgnore => 'Ignore';
@@ -740,7 +760,19 @@ class AppLocalizationsEn extends AppLocalizations {
String get inboxApplyAll => 'Apply all'; String get inboxApplyAll => 'Apply all';
@override @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 @override
String get inboxUnrecognized => 'Not recognized'; String get inboxUnrecognized => 'Not recognized';
@@ -820,10 +852,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get ruleEditorModeRegex => 'Regex'; 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 @override
String get ruleEditorThen => 'Then it is:'; String get ruleEditorThen => 'Then it is:';
@@ -842,6 +870,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get ruleEditorAccountUnchanged => '— keep unchanged —'; 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 @override
String get ruleEditorAdvanced => 'Advanced'; String get ruleEditorAdvanced => 'Advanced';
@@ -1022,6 +1062,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get sourceAppsAlreadyAdded => 'Already added'; 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 @override
String get appBindingsTitle => 'Account bindings'; String get appBindingsTitle => 'Account bindings';
+58 -10
View File
@@ -9,7 +9,7 @@ class AppLocalizationsRu extends AppLocalizations {
AppLocalizationsRu([String locale = 'ru']) : super(locale); AppLocalizationsRu([String locale = 'ru']) : super(locale);
@override @override
String get appTitle => 'NewBudget'; String get appTitle => 'Kitty finances';
@override @override
String get navHome => 'Главная'; String get navHome => 'Главная';
@@ -148,6 +148,13 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get onboardingContinue => 'Продолжить'; String get onboardingContinue => 'Продолжить';
@override
String get onboardingAccountTitle => 'Ваш первый счёт';
@override
String get onboardingAccountSubtitle =>
'Добавьте счёт, чтобы учитывать деньги. Другие счета можно добавить позже.';
@override @override
String get txNewTitle => 'Новая операция'; String get txNewTitle => 'Новая операция';
@@ -487,6 +494,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get parsingAutoApplyHint => String get parsingAutoApplyHint =>
'Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.'; 'Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.';
@override
String get parsingTransferPairingLabel => 'Склеивать переводы между счетами';
@override
String get parsingTransferPairingHint =>
'Два уведомления об одном переводе (списание + зачисление) объединяются в одну транзакцию-перевод на подтверждение.';
@override @override
String get parsingDiagnosticModeLabel => 'Режим диагностики'; String get parsingDiagnosticModeLabel => 'Режим диагностики';
@@ -604,6 +618,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get parsingStatusParsedPartial => 'Частично'; String get parsingStatusParsedPartial => 'Частично';
@override
String get parsingStatusWaitingPair => 'Ждёт пару';
@override
String get parsingStatusPaired => 'Объединено в перевод';
@override
String get parsingUnmergeTransfer => 'Расклеить перевод';
@override @override
String get parsingLogOnline => 'Онлайн'; String get parsingLogOnline => 'Онлайн';
@@ -734,14 +757,12 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Создать правило для «$merchant»'; return 'Создать правило для «$merchant»';
} }
@override
String get inboxConfirmOnce => 'Подтвердить разово';
@override @override
String get inboxConfirm => 'Подтвердить'; String get inboxConfirm => 'Подтвердить';
@override @override
String get inboxMarkMixed => 'Категории различаются'; String get inboxCategoryRequiredHint =>
'Выберите категорию, чтобы подтвердить';
@override @override
String get inboxIgnore => 'Игнорировать'; String get inboxIgnore => 'Игнорировать';
@@ -750,7 +771,19 @@ class AppLocalizationsRu extends AppLocalizations {
String get inboxApplyAll => 'Учесть все'; String get inboxApplyAll => 'Учесть все';
@override @override
String get inboxHideAll => 'Скрыть разово'; String get inboxTransferPairTitle => 'Перевод между счетами';
@override
String get inboxTransferFrom => 'Счёт списания';
@override
String get inboxTransferTo => 'Счёт зачисления';
@override
String get inboxTransferPickAccount => 'Выбрать…';
@override
String get inboxUnpair => 'Расклеить';
@override @override
String get inboxUnrecognized => 'Не распознано'; String get inboxUnrecognized => 'Не распознано';
@@ -832,10 +865,6 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get ruleEditorModeRegex => 'Regex'; String get ruleEditorModeRegex => 'Regex';
@override
String get ruleKindMixedHint =>
'Для этого мерчанта правила-категории не предлагаются: категория выбирается вручную для каждой операции.';
@override @override
String get ruleEditorThen => 'То это:'; String get ruleEditorThen => 'То это:';
@@ -854,6 +883,18 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get ruleEditorAccountUnchanged => '— не менять —'; String get ruleEditorAccountUnchanged => '— не менять —';
@override
String get ruleEditorApp => 'Приложение';
@override
String get ruleEditorAppPick => 'Выберите приложение';
@override
String get ruleEditorNoApps => 'Сначала добавьте приложение-источник';
@override
String get rulesUnknownApp => 'Неизвестное приложение';
@override @override
String get ruleEditorAdvanced => 'Дополнительно'; String get ruleEditorAdvanced => 'Дополнительно';
@@ -1034,6 +1075,13 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get sourceAppsAlreadyAdded => 'Уже добавлено'; String get sourceAppsAlreadyAdded => 'Уже добавлено';
@override
String get sourceAppsSelfMerchantLabel => 'Мерчант — само приложение';
@override
String get sourceAppsSelfMerchantHint =>
'Уведомления не называют продавца: категория выбирается вручную, правила не предлагаются';
@override @override
String get appBindingsTitle => 'Привязки счетов'; String get appBindingsTitle => 'Привязки счетов';
+20 -5
View File
@@ -1,7 +1,7 @@
{ {
"@@locale": "ru", "@@locale": "ru",
"appTitle": "NewBudget", "appTitle": "Kitty finances",
"navHome": "Главная", "navHome": "Главная",
"navAnalytics": "Аналитика", "navAnalytics": "Аналитика",
@@ -84,6 +84,8 @@
"onboardingNameLabel": "Ваше имя", "onboardingNameLabel": "Ваше имя",
"onboardingNameHint": "Например, Алекс", "onboardingNameHint": "Например, Алекс",
"onboardingContinue": "Продолжить", "onboardingContinue": "Продолжить",
"onboardingAccountTitle": "Ваш первый счёт",
"onboardingAccountSubtitle": "Добавьте счёт, чтобы учитывать деньги. Другие счета можно добавить позже.",
"txNewTitle": "Новая операция", "txNewTitle": "Новая операция",
"txEditTitle": "Редактировать операцию", "txEditTitle": "Редактировать операцию",
@@ -208,6 +210,8 @@
"parsingEnableLabel": "Распознавать уведомления", "parsingEnableLabel": "Распознавать уведомления",
"parsingAutoApplyLabel": "Авто-добавление транзакций", "parsingAutoApplyLabel": "Авто-добавление транзакций",
"parsingAutoApplyHint": "Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.", "parsingAutoApplyHint": "Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.",
"parsingTransferPairingLabel": "Склеивать переводы между счетами",
"parsingTransferPairingHint": "Два уведомления об одном переводе (списание + зачисление) объединяются в одну транзакцию-перевод на подтверждение.",
"parsingDiagnosticModeLabel": "Режим диагностики", "parsingDiagnosticModeLabel": "Режим диагностики",
"parsingDiagnosticModeHint": "Ловить каждое уведомление от мониторимых приложений (включая пустые/повторные) с полным дампом всех полей. Временно — для отладки, добавляет шум в журнал парсинга.", "parsingDiagnosticModeHint": "Ловить каждое уведомление от мониторимых приложений (включая пустые/повторные) с полным дампом всех полей. Временно — для отладки, добавляет шум в журнал парсинга.",
"inboxWhyNotAuto": "Почему не автоматически: {reasons}", "inboxWhyNotAuto": "Почему не автоматически: {reasons}",
@@ -247,6 +251,9 @@
"parsingStatusFailed": "Ошибка", "parsingStatusFailed": "Ошибка",
"parsingStatusPendingAi": "Ждёт AI", "parsingStatusPendingAi": "Ждёт AI",
"parsingStatusParsedPartial": "Частично", "parsingStatusParsedPartial": "Частично",
"parsingStatusWaitingPair": "Ждёт пару",
"parsingStatusPaired": "Объединено в перевод",
"parsingUnmergeTransfer": "Расклеить перевод",
"parsingLogOnline": "Онлайн", "parsingLogOnline": "Онлайн",
"parsingLogOffline": "Офлайн", "parsingLogOffline": "Офлайн",
@@ -294,12 +301,15 @@
"@inboxCreateRule": { "placeholders": { "merchant": { "type": "String" }, "category": { "type": "String" } } }, "@inboxCreateRule": { "placeholders": { "merchant": { "type": "String" }, "category": { "type": "String" } } },
"inboxCreateRuleNoCategory": "Создать правило для «{merchant}»", "inboxCreateRuleNoCategory": "Создать правило для «{merchant}»",
"@inboxCreateRuleNoCategory": { "placeholders": { "merchant": { "type": "String" } } }, "@inboxCreateRuleNoCategory": { "placeholders": { "merchant": { "type": "String" } } },
"inboxConfirmOnce": "Подтвердить разово",
"inboxConfirm": "Подтвердить", "inboxConfirm": "Подтвердить",
"inboxMarkMixed": "Категории различаются", "inboxCategoryRequiredHint": "Выберите категорию, чтобы подтвердить",
"inboxIgnore": "Игнорировать", "inboxIgnore": "Игнорировать",
"inboxApplyAll": "Учесть все", "inboxApplyAll": "Учесть все",
"inboxHideAll": "Скрыть разово", "inboxTransferPairTitle": "Перевод между счетами",
"inboxTransferFrom": "Счёт списания",
"inboxTransferTo": "Счёт зачисления",
"inboxTransferPickAccount": "Выбрать…",
"inboxUnpair": "Расклеить",
"inboxUnrecognized": "Не распознано", "inboxUnrecognized": "Не распознано",
"inboxParseErrorTitle": "Ошибка распознавания", "inboxParseErrorTitle": "Ошибка распознавания",
"inboxRetry": "Попробовать снова", "inboxRetry": "Попробовать снова",
@@ -326,13 +336,16 @@
"ruleEditorModeContains": "Содержит", "ruleEditorModeContains": "Содержит",
"ruleEditorModeExact": "Точно", "ruleEditorModeExact": "Точно",
"ruleEditorModeRegex": "Regex", "ruleEditorModeRegex": "Regex",
"ruleKindMixedHint": "Для этого мерчанта правила-категории не предлагаются: категория выбирается вручную для каждой операции.",
"ruleEditorThen": "То это:", "ruleEditorThen": "То это:",
"ruleEditorMerchant": "Мерчант", "ruleEditorMerchant": "Мерчант",
"ruleEditorMerchantHint": "Напр. Wildberries", "ruleEditorMerchantHint": "Напр. Wildberries",
"ruleEditorCategory": "Категория", "ruleEditorCategory": "Категория",
"ruleEditorAccount": "Счёт", "ruleEditorAccount": "Счёт",
"ruleEditorAccountUnchanged": "— не менять —", "ruleEditorAccountUnchanged": "— не менять —",
"ruleEditorApp": "Приложение",
"ruleEditorAppPick": "Выберите приложение",
"ruleEditorNoApps": "Сначала добавьте приложение-источник",
"rulesUnknownApp": "Неизвестное приложение",
"ruleEditorAdvanced": "Дополнительно", "ruleEditorAdvanced": "Дополнительно",
"ruleEditorPriority": "Приоритет", "ruleEditorPriority": "Приоритет",
"ruleEditorMatchesTitle": "Совпадает с (последние 30 дней)", "ruleEditorMatchesTitle": "Совпадает с (последние 30 дней)",
@@ -416,6 +429,8 @@
"sourceAppsSearchHint": "Поиск", "sourceAppsSearchHint": "Поиск",
"sourceAppsPickerEmpty": "Приложения не найдены", "sourceAppsPickerEmpty": "Приложения не найдены",
"sourceAppsAlreadyAdded": "Уже добавлено", "sourceAppsAlreadyAdded": "Уже добавлено",
"sourceAppsSelfMerchantLabel": "Мерчант — само приложение",
"sourceAppsSelfMerchantHint": "Уведомления не называют продавца: категория выбирается вручную, правила не предлагаются",
"appBindingsTitle": "Привязки счетов", "appBindingsTitle": "Привязки счетов",
"appBindingsEmpty": "Пока нет привязок. Добавьте, чтобы связать карту со счётом.", "appBindingsEmpty": "Пока нет привязок. Добавьте, чтобы связать карту со счётом.",
+1 -1
View File
@@ -17,7 +17,7 @@ class NewBudgetApp extends ConsumerWidget {
final locale = ref.watch(appLocaleControllerProvider); final locale = ref.watch(appLocaleControllerProvider);
return MaterialApp.router( return MaterialApp.router(
title: 'NewBudget', title: 'Kitty finances',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: AppTheme.light(), theme: AppTheme.light(),
darkTheme: AppTheme.dark(), darkTheme: AppTheme.dark(),
+4 -2
View File
@@ -59,9 +59,11 @@ GoRouter appRouter(Ref ref) {
), ),
GoRoute( GoRoute(
path: AppRoutes.transactionNew, path: AppRoutes.transactionNew,
pageBuilder: (context, state) => _slideUpPage<void>( pageBuilder: (context, state) => _slideUpPage<String?>(
state, state,
const TransactionFormScreen(), TransactionFormScreen(
prefill: state.extra as TransactionFormPrefill?,
),
), ),
), ),
GoRoute( 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/rule_candidates_dao.dart';
import '../../features/notification_parsing/data/drift/daos/account_bindings_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/source_apps_dao.dart';
import '../../features/notification_parsing/data/drift/daos/transfer_pairing_blocklist_dao.dart';
part 'app_database.g.dart'; part 'app_database.g.dart';
@@ -59,6 +60,7 @@ part 'app_database.g.dart';
RuleCandidatesDao, RuleCandidatesDao,
AccountBindingsDao, AccountBindingsDao,
SourceAppsDao, SourceAppsDao,
TransferPairingBlocklistDao,
], ],
) )
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {
@@ -68,74 +70,13 @@ class AppDatabase extends _$AppDatabase {
AppDatabase.forTesting(super.executor); AppDatabase.forTesting(super.executor);
@override @override
int get schemaVersion => 9; int get schemaVersion => 1;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) async { onCreate: (m) async {
await m.createAll(); 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() { static QueryExecutor _openConnection() {
@@ -11,28 +11,31 @@ part 'habit_analysis_providers.g.dart';
// Состояние фильтров (autoDispose) // Состояние фильтров (autoDispose)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Фильтр по импульсивности: `null` = «Все». /// Фильтр по импульсивности (главная шкала каскада): `null` = «Все».
@riverpod @riverpod
class HabitImpulseFilter extends _$HabitImpulseFilter { class HabitImpulseFilter extends _$HabitImpulseFilter {
@override @override
SpendingImpulse? build() => null; 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 @riverpod
class HabitObligationFilter extends _$HabitObligationFilter { class HabitObligationFilter extends _$HabitObligationFilter {
@override @override
Set<SpendingObligation> build() => const {}; SpendingObligation? build() => null;
void toggle(SpendingObligation value) { void select(SpendingObligation? value) => state = value;
final next = Set<SpendingObligation>.from(state);
if (!next.add(value)) next.remove(value);
state = next;
}
void clear() => state = const {};
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -71,12 +74,16 @@ Map<SpendingImpulse?, int> habitSumByImpulse(Ref ref, String userId) {
return map; return map;
} }
/// Суммы расходов по обязательности (включая ключ `null` = «без оценки»). /// Суммы расходов по обязательности (включая ключ `null` = «без оценки»)
/// внутри выборки активного импульс-фильтра: вторая строка каскада
/// показывает разбивку выбранного импульс-сегмента.
@riverpod @riverpod
Map<SpendingObligation?, int> habitSumByObligation(Ref ref, String userId) { Map<SpendingObligation?, int> habitSumByObligation(Ref ref, String userId) {
final txs = ref.watch(habitMonthTransactionsProvider(userId)); final txs = ref.watch(habitMonthTransactionsProvider(userId));
final impulse = ref.watch(habitImpulseFilterProvider);
final map = <SpendingObligation?, int>{}; final map = <SpendingObligation?, int>{};
for (final t in txs) { for (final t in txs) {
if (impulse != null && t.impulse != impulse) continue;
map[t.obligation] = (map[t.obligation] ?? 0) + t.amount; map[t.obligation] = (map[t.obligation] ?? 0) + t.amount;
} }
return map; return map;
@@ -87,14 +94,11 @@ Map<SpendingObligation?, int> habitSumByObligation(Ref ref, String userId) {
List<Transaction> habitFilteredTransactions(Ref ref, String userId) { List<Transaction> habitFilteredTransactions(Ref ref, String userId) {
final txs = ref.watch(habitMonthTransactionsProvider(userId)); final txs = ref.watch(habitMonthTransactionsProvider(userId));
final impulse = ref.watch(habitImpulseFilterProvider); final impulse = ref.watch(habitImpulseFilterProvider);
final obligations = ref.watch(habitObligationFilterProvider); final obligation = ref.watch(habitObligationFilterProvider);
return txs.where((t) { return txs.where((t) {
if (impulse != null && t.impulse != impulse) return false; if (impulse != null && t.impulse != impulse) return false;
if (obligations.isNotEmpty && if (obligation != null && t.obligation != obligation) return false;
(t.obligation == null || !obligations.contains(t.obligation))) {
return false;
}
return true; return true;
}).toList(); }).toList();
} }
@@ -110,10 +110,15 @@ class _HeaderBar extends ConsumerWidget {
final monthTitle = '$monthCap ${month.year}'; final monthTitle = '$monthCap ${month.year}';
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), padding: const EdgeInsets.fromLTRB(4, 8, 16, 8),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: Icon(Icons.close, color: p.ink),
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
),
Expanded( Expanded(
child: Text( child: Text(
l10n.habitAnalysisTitle, l10n.habitAnalysisTitle,
@@ -197,6 +202,14 @@ class _FilterBlock extends ConsumerWidget {
final totalImpulse = final totalImpulse =
impulseSums.values.fold<int>(0, (s, v) => s + v); 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( return Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
@@ -242,24 +255,38 @@ class _FilterBlock extends ConsumerWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
_ScaleCaption(l10n.habitScaleObligationCaps), _ScaleCaption(l10n.habitScaleObligationCaps),
const SizedBox(height: 6), const SizedBox(height: 6),
Row( AnimatedSize(
children: [ duration: const Duration(milliseconds: 200),
for (final o in SpendingObligation.values) ...[ curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: Row(
children: [
Expanded( Expanded(
child: _ObligationChip( child: _ObligationChip(
title: _obligationFilterLabel(l10n, o), title: l10n.habitFilterAll,
sumMinor: obligationSums[o] ?? 0, sumMinor: totalObligation,
dotColor: _dotColor(hc, o), selected: obligationFilter == null,
selected: obligationFilter.contains(o),
onTap: () => ref onTap: () => ref
.read(habitObligationFilterProvider.notifier) .read(habitObligationFilterProvider.notifier)
.toggle(o), .select(null),
), ),
), ),
if (o != SpendingObligation.values.last) for (final o in obligationValues) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded(
child: _ObligationChip(
title: _obligationFilterLabel(l10n, o),
sumMinor: obligationSums[o] ?? 0,
dotColor: _dotColor(hc, o),
selected: obligationFilter == o,
onTap: () => ref
.read(habitObligationFilterProvider.notifier)
.select(obligationFilter == o ? null : o),
),
),
],
], ],
], ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Divider(height: 1, color: p.line), Divider(height: 1, color: p.line),
@@ -360,14 +387,14 @@ class _ObligationChip extends StatelessWidget {
const _ObligationChip({ const _ObligationChip({
required this.title, required this.title,
required this.sumMinor, required this.sumMinor,
required this.dotColor,
required this.selected, required this.selected,
required this.onTap, required this.onTap,
this.dotColor,
}); });
final String title; final String title;
final int sumMinor; final int sumMinor;
final Color dotColor; final Color? dotColor;
final bool selected; final bool selected;
final VoidCallback onTap; final VoidCallback onTap;
@@ -392,13 +419,15 @@ class _ObligationChip extends StatelessWidget {
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Container( if (dotColor != null) ...[
width: 8, Container(
height: 8, width: 8,
decoration: height: 8,
BoxDecoration(color: dotColor, shape: BoxShape.circle), decoration:
), BoxDecoration(color: dotColor, shape: BoxShape.circle),
const SizedBox(width: 6), ),
const SizedBox(width: 6),
],
Flexible( Flexible(
child: Text( child: Text(
title, title,
@@ -1,9 +1,11 @@
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/database/converters/enum_converters.dart';
import '../../accounts/application/account_providers.dart'; import '../../accounts/application/account_providers.dart';
import '../../transactions/application/transaction_providers.dart';
import '../../transactions/application/transactions_controller.dart'; import '../../transactions/application/transactions_controller.dart';
import '../data/parser/draft_codec.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/parse_draft.dart';
import '../domain/entities/raw_message.dart'; import '../domain/entities/raw_message.dart';
import '../domain/enums.dart'; import '../domain/enums.dart';
@@ -26,6 +28,35 @@ Stream<int> inboxCount(Ref ref, String userId) =>
Stream<List<RawMessage>> parsingLog(Ref ref, String userId) => Stream<List<RawMessage>> parsingLog(Ref ref, String userId) =>
ref.watch(rawMessagesRepositoryProvider).watchAll(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). Все мутации — явные тапы пользователя. /// Действия над карточками Inbox (§9.1). Все мутации — явные тапы пользователя.
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
class InboxController extends _$InboxController { class InboxController extends _$InboxController {
@@ -61,6 +92,7 @@ class InboxController extends _$InboxController {
await ref.read(parseRulesRepositoryProvider).create( await ref.read(parseRulesRepositoryProvider).create(
userId: userId, userId: userId,
packageName: message.packageName,
kind: ParseRuleKind.merchantToCategory, kind: ParseRuleKind.merchantToCategory,
matchMode: matchMode, matchMode: matchMode,
pattern: pattern, pattern: pattern,
@@ -125,60 +157,12 @@ class InboxController extends _$InboxController {
} }
} }
/// «Категории различаются»: помечает мерчанта как mixed (§9.1) — у него /// Привязка сообщения к транзакции, сохранённой через полную форму
/// категория варьируется (Ozon, маркетплейсы), поэтому merchant→category /// (карандаш на карточке): status → applied, сообщение уходит из Inbox.
/// правило для него бессмысленно. Создаёт правило-маркер `mixedMerchant`, Future<void> markApplied(RawMessage message, String transactionId) =>
/// удаляет уже созданное merchant→category правило и наблюдённого кандидата, ref
/// затем перезаписывает кеш `draftJson` без suggestion — карточка тут же .read(rawMessagesRepositoryProvider)
/// переключается на «подтвердить разово». .linkTransaction(message.id, transactionId);
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 → pending, попытки → 0). Воркер переобработает его заново. /// (status → pending, попытки → 0). Воркер переобработает его заново.
@@ -186,11 +170,157 @@ class InboxController extends _$InboxController {
await ref.read(rawMessagesRepositoryProvider).resetForRetry(message.id); await ref.read(rawMessagesRepositoryProvider).resetForRetry(message.id);
} }
/// «Игнорировать»: статус ignored, без правила. /// «Игнорировать»: статус ignored, без правила. У склеенной карточки
/// перевода гасит обе половинки.
Future<void> ignore(RawMessage message) async { Future<void> ignore(RawMessage message) async {
await ref final repo = ref.read(rawMessagesRepositoryProvider);
.read(rawMessagesRepositoryProvider) await repo.updateStatus(message.id, RawMessageStatus.ignored);
.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(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). /// «Игнорировать» + правило-исключение (kind=ignore).
@@ -204,6 +334,7 @@ class InboxController extends _$InboxController {
try { try {
await ref.read(parseRulesRepositoryProvider).create( await ref.read(parseRulesRepositoryProvider).create(
userId: userId, userId: userId,
packageName: message.packageName,
kind: ParseRuleKind.ignore, kind: ParseRuleKind.ignore,
matchMode: matchMode, matchMode: matchMode,
pattern: pattern, pattern: pattern,
@@ -218,7 +349,9 @@ class InboxController extends _$InboxController {
} }
} }
/// «Учесть все транзакции»: массовое «подтвердить разово». /// «Учесть все транзакции»: массовое «подтвердить разово». Склеенные
/// карточки переводов подтверждаются через [confirmPair] и пропускаются,
/// пока не выбраны оба счёта.
Future<void> applyAll(String userId) async { Future<void> applyAll(String userId) async {
final messages = final messages =
await ref.read(rawMessagesRepositoryProvider).watchInbox(userId).first; await ref.read(rawMessagesRepositoryProvider).watchInbox(userId).first;
@@ -227,6 +360,23 @@ class InboxController extends _$InboxController {
for (final msg in messages) { for (final msg in messages) {
final bundle = decodeDraftBundle(msg.draftJson); final bundle = decodeDraftBundle(msg.draftJson);
if (bundle == null) continue; 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; final accountId = bundle.draft.accountId ?? defaultAccount?.id;
if (accountId == null) continue; if (accountId == null) continue;
await confirmOnce( 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). /// сообщение того же мерчанта прошло gate молча (account score 100).
Future<void> _maybeBind( 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/raw_messages_repository_impl.dart';
import '../data/repositories/rule_candidates_repository_impl.dart'; import '../data/repositories/rule_candidates_repository_impl.dart';
import '../data/repositories/source_apps_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/account_bindings_repository.dart';
import '../domain/repositories/parse_rules_repository.dart'; import '../domain/repositories/parse_rules_repository.dart';
import '../domain/repositories/raw_messages_repository.dart'; import '../domain/repositories/raw_messages_repository.dart';
import '../domain/repositories/rule_candidates_repository.dart'; import '../domain/repositories/rule_candidates_repository.dart';
import '../domain/repositories/source_apps_repository.dart'; import '../domain/repositories/source_apps_repository.dart';
import '../domain/repositories/transfer_pairing_blocklist_repository.dart';
part 'notification_parsing_providers.g.dart'; part 'notification_parsing_providers.g.dart';
@@ -39,6 +41,12 @@ AccountBindingsRepository accountBindingsRepository(Ref ref) =>
SourceAppsRepository sourceAppsRepository(Ref ref) => SourceAppsRepository sourceAppsRepository(Ref ref) =>
SourceAppsRepositoryImpl(ref.watch(appDatabaseProvider).sourceAppsDao); SourceAppsRepositoryImpl(ref.watch(appDatabaseProvider).sourceAppsDao);
@Riverpod(keepAlive: true)
TransferPairingBlocklistRepository transferPairingBlocklistRepository(
Ref ref) =>
TransferPairingBlocklistRepositoryImpl(
ref.watch(appDatabaseProvider).transferPairingBlocklistDao);
/// Множество включённых packageName пользователя. Drift-стрим: эмитит каждое /// Множество включённых packageName пользователя. Drift-стрим: эмитит каждое
/// изменение allowlist, чтобы native-синк в [NotificationIngestWorker] не /// изменение allowlist, чтобы native-синк в [NotificationIngestWorker] не
/// протухал до перезапуска. Подписка на стрим требует прямого слушателя /// протухал до перезапуска. Подписка на стрим требует прямого слушателя
@@ -1,8 +1,10 @@
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/database/converters/enum_converters.dart';
import '../../accounts/application/account_providers.dart'; import '../../accounts/application/account_providers.dart';
import '../../categories/application/category_providers.dart'; import '../../categories/application/category_providers.dart';
import '../../categories/domain/entities/category.dart'; import '../../categories/domain/entities/category.dart';
import '../../transactions/application/transaction_providers.dart';
import '../../transactions/application/transactions_controller.dart'; import '../../transactions/application/transactions_controller.dart';
import '../data/deepseek/deepseek_client.dart'; import '../data/deepseek/deepseek_client.dart';
import '../data/parser/account_resolver.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/draft_codec.dart';
import '../data/parser/rule_lookup.dart'; import '../data/parser/rule_lookup.dart';
import '../data/parser/rule_suggester.dart'; import '../data/parser/rule_suggester.dart';
import '../data/parser/transfer_pair_matcher.dart';
import '../domain/entities/parse_draft.dart'; import '../domain/entities/parse_draft.dart';
import '../domain/entities/raw_message.dart'; import '../domain/entities/raw_message.dart';
import '../domain/entities/rule_candidate.dart'; import '../domain/entities/rule_candidate.dart';
@@ -48,14 +51,12 @@ class ParsingPipeline {
if (!settings.enabled) return; // фича выключена — оставляем pending. if (!settings.enabled) return; // фича выключена — оставляем pending.
// Allowlist (§A): парсим только включённые приложения-источники. Делаем // Allowlist (§A): парсим только включённые приложения-источники. Делаем
// ДО AI, чтобы не тратить токены на посторонние пакеты. Снапшот берём из // ДО AI, чтобы не тратить токены на посторонние пакеты. Точечный lookup
// репозитория напрямую (`.first` Drift-стрима): свежий на каждый вызов; // вместо снапшота стрима: заодно нужен флаг selfMerchant источника.
// stream-провайдер без прямого слушателя не подписался бы на стрим. final sourceApp = await _ref
final enabled = await _ref
.read(sourceAppsRepositoryProvider) .read(sourceAppsRepositoryProvider)
.watchEnabledPackages(userId) .findByPackageName(userId, msg.packageName);
.first; if (sourceApp == null || !sourceApp.enabled) {
if (!enabled.contains(msg.packageName)) {
await _ref await _ref
.read(rawMessagesRepositoryProvider) .read(rawMessagesRepositoryProvider)
.updateStatus(msg.id, RawMessageStatus.ignored); .updateStatus(msg.id, RawMessageStatus.ignored);
@@ -66,8 +67,9 @@ class ParsingPipeline {
// на пуши, которые пользователь явно просил пропускать («Доставлен заказ»). // на пуши, которые пользователь явно просил пропускать («Доставлен заказ»).
// `exact`-правила матчат имя мерчанта, доступное только после AI, — их // `exact`-правила матчат имя мерчанта, доступное только после AI, — их
// проверяет findIgnoreRule в _runPipeline. // проверяет findIgnoreRule в _runPipeline.
final bodyRules = final bodyRules = await _ref
await _ref.read(parseRulesRepositoryProvider).getEnabledByUser(userId); .read(parseRulesRepositoryProvider)
.getEnabledForApp(userId, msg.packageName);
if (findBodyIgnoreRule(bodyRules, msg.body) != null) { if (findBodyIgnoreRule(bodyRules, msg.body) != null) {
await _ref await _ref
.read(rawMessagesRepositoryProvider) .read(rawMessagesRepositoryProvider)
@@ -76,7 +78,8 @@ class ParsingPipeline {
} }
// Извлечение через AI (regex-этап удалён). Без AI/сети — Inbox/ignored. // Извлечение через AI (regex-этап удалён). Без AI/сети — Inbox/ignored.
await _extractViaAi(userId, msg, settings); await _extractViaAi(userId, msg, settings,
selfMerchant: sourceApp.selfMerchant);
} }
/// Извлечение через AI: спам без цифр → ignored; зовём AI (если разрешён), /// Извлечение через AI: спам без цифр → ignored; зовём AI (если разрешён),
@@ -84,8 +87,9 @@ class ParsingPipeline {
Future<void> _extractViaAi( Future<void> _extractViaAi(
String userId, String userId,
RawMessage msg, RawMessage msg,
ParsingSettings settings, ParsingSettings settings, {
) async { required bool selfMerchant,
}) async {
final repo = _ref.read(rawMessagesRepositoryProvider); final repo = _ref.read(rawMessagesRepositoryProvider);
final settingsCtrl = _ref.read(parsingSettingsControllerProvider.notifier); final settingsCtrl = _ref.read(parsingSettingsControllerProvider.notifier);
@@ -161,7 +165,7 @@ class ParsingPipeline {
); );
case AiParseStatus.draft: case AiParseStatus.draft:
await _runPipeline(userId, msg, outcome.draft!, settings, await _runPipeline(userId, msg, outcome.draft!, settings,
categories: categories); categories: categories, selfMerchant: selfMerchant);
} }
} on DeepSeekNetworkException { } on DeepSeekNetworkException {
await repo.updateAfterParse( await repo.updateAfterParse(
@@ -189,18 +193,30 @@ class ParsingPipeline {
} }
/// Общий хвост pipeline (§5, шаги 38) для AI-draft. /// Общий хвост pipeline (§5, шаги 38) для AI-draft.
///
/// [selfMerchant] — источник помечен «мерчант — само приложение» (Ozon):
/// AI-подсказка категории глушится, merchant→category правило не
/// предлагается — категорию пользователь выбирает вручную на карточке.
Future<void> _runPipeline( Future<void> _runPipeline(
String userId, String userId,
RawMessage msg, RawMessage msg,
ParseDraft draft0, ParseDraft draft0,
ParsingSettings settings, { ParsingSettings settings, {
List<Category>? categories, List<Category>? categories,
bool selfMerchant = false,
}) async { }) async {
final repo = _ref.read(rawMessagesRepositoryProvider); final repo = _ref.read(rawMessagesRepositoryProvider);
if (selfMerchant) {
// «Нет AI-префилла»: карточка читает подсказку из draftJson, поэтому
// достаточно снять её здесь — виджету флаг знать не нужно.
draft0 = draft0.copyWith(categorySuggestion: null);
}
// 4. Правила грузим раньше — нужны резолверу (senderToAccount). // 4. Правила грузим раньше — нужны резолверу (senderToAccount).
final rules = final rules = await _ref
await _ref.read(parseRulesRepositoryProvider).getEnabledByUser(userId); .read(parseRulesRepositoryProvider)
.getEnabledForApp(userId, msg.packageName);
if (findIgnoreRule(rules, body: msg.body, merchantRaw: draft0.merchantRaw) != if (findIgnoreRule(rules, body: msg.body, merchantRaw: draft0.merchantRaw) !=
null) { null) {
await repo.updateStatus(msg.id, RawMessageStatus.ignored); await repo.updateStatus(msg.id, RawMessageStatus.ignored);
@@ -249,16 +265,12 @@ class ParsingPipeline {
); );
} }
// Предложение правила для Inbox (только для незнакомого мерчанта, если // Предложение правила для Inbox только для незнакомого мерчанта и не
// пользователь не пометил его как «категории различаются»). Без мерчанта // для selfMerchant-источников (мерчант там — само приложение, правило
// или для mixed-мерчанта suggestion остаётся null → карточка покажет // бессмысленно). Без suggestion карточка покажет «Подтвердить» вместо
// «подтвердить разово» вместо кнопки «Создать правило». // кнопки «Создать правило».
RuleSuggestion? suggestion; RuleSuggestion? suggestion;
if (rule == null && if (!selfMerchant && rule == null && draft.merchantRaw != null) {
draft.merchantRaw != null &&
findMixedMerchantRule(rules,
body: msg.body, merchantRaw: draft.merchantRaw) ==
null) {
// AI-подсказка категории: матчим имя на существующую категорию (§7). // AI-подсказка категории: матчим имя на существующую категорию (§7).
String? aiCategoryId; String? aiCategoryId;
String? aiCategoryName; String? aiCategoryName;
@@ -300,6 +312,25 @@ class ParsingPipeline {
categoryCandidate: candidate, 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). // 8. Gate: чек-лист именованных проверок (decision_gate.dart).
final gate = decide( final gate = decide(
autoApplyEnabled: settings.autoApplyEnabled, autoApplyEnabled: settings.autoApplyEnabled,
@@ -379,4 +410,237 @@ class ParsingPipeline {
.incrementMatchCount(bindingId); .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({ const ParsingSettings({
required this.enabled, required this.enabled,
required this.autoApplyEnabled, required this.autoApplyEnabled,
required this.transferPairingEnabled,
required this.aiConsentGiven, required this.aiConsentGiven,
required this.aiModel, required this.aiModel,
required this.aiDailyTokenLimit, required this.aiDailyTokenLimit,
@@ -25,6 +26,11 @@ class ParsingSettings {
/// gate-проверок (см. `AutoApplyCheck` в decision_gate.dart). /// gate-проверок (см. `AutoApplyCheck` в decision_gate.dart).
final bool autoApplyEnabled; final bool autoApplyEnabled;
/// Склейка переводов между счетами: transfer-половинки ждут пару
/// (`waitingPair`) и объединяются в одну transfer-транзакцию. При
/// выключении идут обычным одиночным путём.
final bool transferPairingEnabled;
/// Дано ли согласие на отправку текста уведомлений в AI (§7/§12.6). /// Дано ли согласие на отправку текста уведомлений в AI (§7/§12.6).
/// Без него AI не вызывается — работает только regex. /// Без него AI не вызывается — работает только regex.
final bool aiConsentGiven; final bool aiConsentGiven;
@@ -50,6 +56,7 @@ class ParsingSettings {
ParsingSettings copyWith({ ParsingSettings copyWith({
bool? enabled, bool? enabled,
bool? autoApplyEnabled, bool? autoApplyEnabled,
bool? transferPairingEnabled,
bool? aiConsentGiven, bool? aiConsentGiven,
String? aiModel, String? aiModel,
int? aiDailyTokenLimit, int? aiDailyTokenLimit,
@@ -60,6 +67,8 @@ class ParsingSettings {
ParsingSettings( ParsingSettings(
enabled: enabled ?? this.enabled, enabled: enabled ?? this.enabled,
autoApplyEnabled: autoApplyEnabled ?? this.autoApplyEnabled, autoApplyEnabled: autoApplyEnabled ?? this.autoApplyEnabled,
transferPairingEnabled:
transferPairingEnabled ?? this.transferPairingEnabled,
aiConsentGiven: aiConsentGiven ?? this.aiConsentGiven, aiConsentGiven: aiConsentGiven ?? this.aiConsentGiven,
aiModel: aiModel ?? this.aiModel, aiModel: aiModel ?? this.aiModel,
aiDailyTokenLimit: aiDailyTokenLimit:
@@ -74,6 +83,7 @@ const _kEnabled = 'parsing_enabled';
// Старый ключ 'parsing_auto_apply_strictness' (75/85/95) больше не читается: // Старый ключ 'parsing_auto_apply_strictness' (75/85/95) больше не читается:
// числовой гейт заменён чек-листом, фича до замены не срабатывала ни разу. // числовой гейт заменён чек-листом, фича до замены не срабатывала ни разу.
const _kAutoApply = 'parsing_auto_apply_enabled'; const _kAutoApply = 'parsing_auto_apply_enabled';
const _kTransferPairing = 'parsing_transfer_pairing_enabled';
const _kAiConsent = 'ai_consent'; const _kAiConsent = 'ai_consent';
const _kAiModel = 'ai_model'; const _kAiModel = 'ai_model';
const _kAiDailyLimit = 'ai_daily_token_limit'; const _kAiDailyLimit = 'ai_daily_token_limit';
@@ -84,6 +94,7 @@ const kDefaultAiModel = 'deepseek-chat';
const _defaultSettings = ParsingSettings( const _defaultSettings = ParsingSettings(
enabled: true, enabled: true,
autoApplyEnabled: true, autoApplyEnabled: true,
transferPairingEnabled: true,
aiConsentGiven: false, aiConsentGiven: false,
aiModel: kDefaultAiModel, aiModel: kDefaultAiModel,
aiDailyTokenLimit: null, aiDailyTokenLimit: null,
@@ -107,6 +118,7 @@ class ParsingSettingsController extends _$ParsingSettingsController {
final dao = ref.watch(appDatabaseProvider).settingsDao; final dao = ref.watch(appDatabaseProvider).settingsDao;
final enabledStr = await dao.getPreference(_kEnabled); final enabledStr = await dao.getPreference(_kEnabled);
final autoApplyStr = await dao.getPreference(_kAutoApply); final autoApplyStr = await dao.getPreference(_kAutoApply);
final pairingStr = await dao.getPreference(_kTransferPairing);
final consentStr = await dao.getPreference(_kAiConsent); final consentStr = await dao.getPreference(_kAiConsent);
final modelStr = await dao.getPreference(_kAiModel); final modelStr = await dao.getPreference(_kAiModel);
final limitStr = await dao.getPreference(_kAiDailyLimit); final limitStr = await dao.getPreference(_kAiDailyLimit);
@@ -119,6 +131,9 @@ class ParsingSettingsController extends _$ParsingSettingsController {
autoApplyEnabled: autoApplyStr == null autoApplyEnabled: autoApplyStr == null
? _defaultSettings.autoApplyEnabled ? _defaultSettings.autoApplyEnabled
: autoApplyStr == 'true', : autoApplyStr == 'true',
transferPairingEnabled: pairingStr == null
? _defaultSettings.transferPairingEnabled
: pairingStr == 'true',
aiConsentGiven: consentStr == 'true', aiConsentGiven: consentStr == 'true',
aiModel: (modelStr != null && modelStr.isNotEmpty) aiModel: (modelStr != null && modelStr.isNotEmpty)
? modelStr ? modelStr
@@ -144,6 +159,12 @@ class ParsingSettingsController extends _$ParsingSettingsController {
state = AsyncData(current.copyWith(autoApplyEnabled: value)); 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 { Future<void> setDiagnosticMode(bool value) async {
await _set(_kDiagnosticMode, '$value'); await _set(_kDiagnosticMode, '$value');
final current = state.value ?? _defaultSettings; final current = state.value ?? _defaultSettings;
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../domain/entities/raw_message.dart'; import '../domain/entities/raw_message.dart';
@@ -9,11 +11,6 @@ import 'parsing_settings_controller.dart';
part 'parsing_worker.g.dart'; part 'parsing_worker.g.dart';
/// Поток необработанных сообщений — вход воркера.
@riverpod
Stream<List<RawMessage>> pendingMessages(Ref ref, String userId) =>
ref.watch(rawMessagesRepositoryProvider).watchPending(userId);
/// ParsingWorker (§5): foreground-драйвер pipeline. Слушает /// ParsingWorker (§5): foreground-драйвер pipeline. Слушает
/// `raw_messages.pending` и прогоняет каждое сообщение через [ParsingPipeline]. /// `raw_messages.pending` и прогоняет каждое сообщение через [ParsingPipeline].
/// Отвечает только за «когда запускать» (триггер/дедуп/ретраи); сама обработка /// Отвечает только за «когда запускать» (триггер/дедуп/ретраи); сама обработка
@@ -21,7 +18,14 @@ Stream<List<RawMessage>> pendingMessages(Ref ref, String userId) =>
/// поверх того же кода без дублирования. /// поверх того же кода без дублирования.
/// ///
/// Идемпотентен: при возврате сообщения в `pending` перепарсивается. /// Идемпотентен: при возврате сообщения в `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) @Riverpod(keepAlive: true)
class ParsingWorker extends _$ParsingWorker { class ParsingWorker extends _$ParsingWorker {
final Set<String> _inFlight = {}; final Set<String> _inFlight = {};
@@ -30,25 +34,31 @@ class ParsingWorker extends _$ParsingWorker {
// (иначе при перезапуске приложения они не ретраятся — §7). // (иначе при перезапуске приложения они не ретраятся — §7).
bool _wasOnline = false; bool _wasOnline = false;
// Сериализация sweep склейки переводов: одновременно работает максимум
// один sweepPairs; запрос, пришедший во время работы, выполняется следом.
bool _sweeping = false;
bool _sweepRequested = false;
Timer? _pairTimer;
@override @override
void build(String userId) { void build(String userId) {
final sub = ref.listen( // Первая эмиссия Drift-стрима — текущий снапшот: бэклог `pending`,
pendingMessagesProvider(userId), // накопившийся до старта приложения, дренится сразу.
(_, next) { final pendingSub = ref
final list = next.value; .read(rawMessagesRepositoryProvider)
if (list == null || list.isEmpty) return; .watchPending(userId)
// Фича выключена: pipeline всё равно no-op'нул бы каждое сообщение — .listen((list) {
// не прогоняем растущий pending заново на каждую новую вставку. if (list.isEmpty) return;
// Обратное включение тумблера запускает явный drain (см. enabledSub). // Фича выключена: pipeline всё равно no-op'нул бы каждое сообщение —
if (ref.read(parsingSettingsControllerProvider).value?.enabled == // не прогоняем растущий pending заново на каждую новую вставку.
false) { // Обратное включение тумблера запускает явный drain (см. enabledSub).
return; if (ref.read(parsingSettingsControllerProvider).value?.enabled ==
} false) {
_drain(userId, list); return;
}, }
fireImmediately: true, _drain(userId, list);
); });
ref.onDispose(sub.close); ref.onDispose(pendingSub.cancel);
// Размораживаем очередь при обратном включении фичи: raw_messages не // Размораживаем очередь при обратном включении фичи: raw_messages не
// менялись, поэтому pending-стрим сам не переэмитит застрявшие сообщения. // менялись, поэтому pending-стрим сам не переэмитит застрявшие сообщения.
@@ -75,6 +85,72 @@ class ParsingWorker extends _$ParsingWorker {
}, },
); );
ref.onDispose(onlineSub.close); 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` — явный триггер при обратном включении /// Drain текущего снапшота `pending` — явный триггер при обратном включении
@@ -19,18 +19,21 @@ Future<ParseRule?> parseRuleById(Ref ref, String id) =>
ref.watch(parseRulesRepositoryProvider).findById(id); ref.watch(parseRulesRepositoryProvider).findById(id);
/// Live-превью (§9.3): сообщения за 30 дней, совпадающие с паттерном. /// Live-превью (§9.3): сообщения за 30 дней, совпадающие с паттерном.
/// [packageName] скоупит превью до приложения правила (null — без фильтра).
@riverpod @riverpod
Future<List<RawMessage>> ruleMatchPreview( Future<List<RawMessage>> ruleMatchPreview(
Ref ref, Ref ref,
String userId, String userId,
String pattern, String pattern,
MatchMode mode, MatchMode mode,
String? packageName,
) async { ) async {
if (pattern.trim().isEmpty) return const []; if (pattern.trim().isEmpty) return const [];
final since = DateTime.now().subtract(const Duration(days: 30)); final since = DateTime.now().subtract(const Duration(days: 30));
final messages = final messages =
await ref.watch(rawMessagesRepositoryProvider).recentByUser(userId, since); await ref.watch(rawMessagesRepositoryProvider).recentByUser(userId, since);
return messages return messages
.where((m) => packageName == null || m.packageName == packageName)
.where((m) => patternMatches(pattern, mode, body: m.body)) .where((m) => patternMatches(pattern, mode, body: m.body))
.toList(); .toList();
} }
@@ -43,6 +46,7 @@ class RulesController extends _$RulesController {
Future<ParseRule> create({ Future<ParseRule> create({
required String userId, required String userId,
required String packageName,
required ParseRuleKind kind, required ParseRuleKind kind,
required MatchMode matchMode, required MatchMode matchMode,
required String pattern, required String pattern,
@@ -55,6 +59,7 @@ class RulesController extends _$RulesController {
try { try {
final rule = await ref.read(parseRulesRepositoryProvider).create( final rule = await ref.read(parseRulesRepositoryProvider).create(
userId: userId, userId: userId,
packageName: packageName,
kind: kind, kind: kind,
matchMode: matchMode, matchMode: matchMode,
pattern: pattern, pattern: pattern,
@@ -38,6 +38,9 @@ class SourceAppsController extends _$SourceAppsController {
Future<void> setEnabled(String id, {required bool enabled}) => Future<void> setEnabled(String id, {required bool enabled}) =>
ref.read(sourceAppsRepositoryProvider).setEnabled(id, enabled: 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) => Future<void> delete(String id) =>
ref.read(sourceAppsRepositoryProvider).deleteById(id); ref.read(sourceAppsRepositoryProvider).deleteById(id);
} }
@@ -38,6 +38,17 @@ String buildSystemPrompt({required List<String> categoryNames}) {
- "counterpartyPhone": телефон контрагента (для переводов по СБП), либо null. - "counterpartyPhone": телефон контрагента (для переводов по СБП), либо null.
- "dateTime": ISO-8601 дата-время операции из текста, либо null. - "dateTime": ISO-8601 дата-время операции из текста, либо null.
- "kind": одно из "purchase" | "refund" | "transfer_out" | "transfer_in" | "fee" | "balance" | "other". - "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": наиболее подходящая категория из списка пользователя - "categorySuggestion": наиболее подходящая категория из списка пользователя
(точное название из списка) или null, если ничего не подходит. (точное название из списка) или null, если ничего не подходит.
@@ -23,11 +23,17 @@ class ParseRulesDao extends DatabaseAccessor<AppDatabase>
Future<List<ParseRulesTableData>> getByUser(String userId) => Future<List<ParseRulesTableData>> getByUser(String userId) =>
(select(parseRulesTable)..where((t) => t.userId.equals(userId))).get(); (select(parseRulesTable)..where((t) => t.userId.equals(userId))).get();
/// Только активные правила — для pipeline (rule_lookup). /// Активные правила приложения [packageName] — для pipeline (rule_lookup).
Future<List<ParseRulesTableData>> getEnabledByUser(String userId) => /// NULL-строки (легаси без привязки) матчатся в любом приложении.
Future<List<ParseRulesTableData>> getEnabledForApp(
String userId,
String packageName,
) =>
(select(parseRulesTable) (select(parseRulesTable)
..where((t) => ..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([ ..orderBy([
(t) => OrderingTerm.desc(t.priority), (t) => OrderingTerm.desc(t.priority),
(t) => OrderingTerm.desc(t.matchCount), (t) => OrderingTerm.desc(t.matchCount),
@@ -1,11 +1,13 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import '../../../../../core/database/app_database.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 '../tables/raw_messages_table.dart';
import '../../../domain/enums.dart'; import '../../../domain/enums.dart';
part 'raw_messages_dao.g.dart'; part 'raw_messages_dao.g.dart';
@DriftAccessor(tables: [RawMessagesTable]) @DriftAccessor(tables: [RawMessagesTable, TransactionsTable])
class RawMessagesDao extends DatabaseAccessor<AppDatabase> class RawMessagesDao extends DatabaseAccessor<AppDatabase>
with _$RawMessagesDaoMixin { with _$RawMessagesDaoMixin {
RawMessagesDao(super.db); RawMessagesDao(super.db);
@@ -43,6 +45,17 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])) ..orderBy([(t) => OrderingTerm.desc(t.receivedAt)]))
.watch(); .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`/…), новые сверху. /// Включает любые статусы (`applied`/`ignored`/`failed`/…), новые сверху.
Stream<List<RawMessagesTableData>> watchAll(String userId, {int limit = 200}) => 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)); .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 ──────────────────────────────────────────────────────────────── // ── Lookups ────────────────────────────────────────────────────────────────
Future<RawMessagesTableData?> findById(String id) => Future<RawMessagesTableData?> findById(String id) =>
@@ -96,6 +142,22 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
..limit(1)) ..limit(1))
.getSingleOrNull(); .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 ────────────────────────────────────────────────────────────── // ── Mutations ──────────────────────────────────────────────────────────────
Future<void> insert(RawMessagesTableCompanion companion) => 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). /// Привязка к созданной транзакции (auto-apply).
Future<void> linkTransaction(String id, String transactionId) => Future<void> linkTransaction(String id, String transactionId) =>
(update(rawMessagesTable)..where((t) => t.id.equals(id))).write( (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))) (update(sourceAppsTable)..where((t) => t.id.equals(id)))
.write(SourceAppsTableCompanion(enabled: Value(enabled))); .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) => Future<int> deleteById(String id) =>
(delete(sourceAppsTable)..where((t) => t.id.equals(id))).go(); (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 => TextColumn get userId =>
text().references(UsersTable, #id, onDelete: KeyAction.cascade)(); 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 /// merchantToCategory | senderToAccount | ignore
TextColumn get kind => text().map(const ParseRuleKindConverter())(); TextColumn get kind => text().map(const ParseRuleKindConverter())();
@@ -49,6 +49,14 @@ class RawMessagesTable extends Table {
.references(TransactionsTable, #id, onDelete: KeyAction.setNull) .references(TransactionsTable, #id, onDelete: KeyAction.setNull)
.nullable()(); .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)(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
@override @override
@@ -21,6 +21,12 @@ class SourceAppsTable extends Table {
TextColumn get displayName => text().nullable()(); TextColumn get displayName => text().nullable()();
BoolColumn get enabled => boolean().withDefault(const Constant(true))(); 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)(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
@override @override
@@ -6,6 +6,7 @@ extension ParseRuleMapper on ParseRulesTableData {
ParseRule toDomain() => ParseRule( ParseRule toDomain() => ParseRule(
id: id, id: id,
userId: userId, userId: userId,
packageName: packageName,
kind: kind, kind: kind,
matchMode: matchMode, matchMode: matchMode,
pattern: pattern, pattern: pattern,
@@ -22,6 +22,8 @@ extension RawMessageMapper on RawMessagesTableData {
confidenceMerchant: confidenceMerchant, confidenceMerchant: confidenceMerchant,
confidenceCategory: confidenceCategory, confidenceCategory: confidenceCategory,
transactionId: transactionId, transactionId: transactionId,
pairedWithId: pairedWithId,
pairDeadline: pairDeadline,
createdAt: createdAt, createdAt: createdAt,
); );
} }
@@ -9,6 +9,7 @@ extension SourceAppMapper on SourceAppsTableData {
packageName: packageName, packageName: packageName,
displayName: displayName, displayName: displayName,
enabled: enabled, enabled: enabled,
selfMerchant: selfMerchant,
createdAt: createdAt, createdAt: createdAt,
); );
} }
@@ -17,6 +17,10 @@ class DraftBundle {
required this.draft, required this.draft,
this.suggestion, this.suggestion,
this.failedChecks = const {}, this.failedChecks = const {},
this.accountTrusted = false,
this.pairedRawMessageId,
this.preMerge,
this.mergeUndo,
}); });
final ParseDraft draft; final ParseDraft draft;
@@ -25,18 +29,43 @@ class DraftBundle {
/// Gate-проверки, не пройденные при отправке в Inbox — для строки /// Gate-проверки, не пройденные при отправке в Inbox — для строки
/// «почему не автоматически» в карточке/журнале. /// «почему не автоматически» в карточке/журнале.
final Set<AutoApplyCheck> failedChecks; 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( String encodeDraftBundle(
ParseDraft draft, ParseDraft draft,
RuleSuggestion? suggestion, { RuleSuggestion? suggestion, {
Set<AutoApplyCheck> failedChecks = const {}, Set<AutoApplyCheck> failedChecks = const {},
bool accountTrusted = false,
String? pairedRawMessageId,
Map<String, dynamic>? preMerge,
Map<String, dynamic>? mergeUndo,
}) { }) {
return jsonEncode(<String, dynamic>{ return jsonEncode(<String, dynamic>{
'draft': _draftToJson(draft), 'draft': _draftToJson(draft),
if (suggestion != null) 'suggestion': _suggestionToJson(suggestion), if (suggestion != null) 'suggestion': _suggestionToJson(suggestion),
if (failedChecks.isNotEmpty) if (failedChecks.isNotEmpty)
'failedChecks': failedChecks.map((c) => c.name).toList(), '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), draft: _draftFromJson(draftMap),
suggestion: suggMap == null ? null : _suggestionFromJson(suggMap), suggestion: suggMap == null ? null : _suggestionFromJson(suggMap),
failedChecks: _failedChecksFromJson(map['failedChecks']), 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; 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) для сообщения. /// Ищет активное правило-исключение (kind=ignore) для сообщения.
ParseRule? findIgnoreRule( ParseRule? findIgnoreRule(
List<ParseRule> rules, { 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()); .map((rows) => rows.map((r) => r.toDomain()).toList());
@override @override
Future<List<ParseRule>> getEnabledByUser(String userId) async => Future<List<ParseRule>> getEnabledForApp(
(await _dao.getEnabledByUser(userId)).map((r) => r.toDomain()).toList(); String userId,
String packageName,
) async =>
(await _dao.getEnabledForApp(userId, packageName))
.map((r) => r.toDomain())
.toList();
@override @override
Future<List<ParseRule>> getByUser(String userId) async => Future<List<ParseRule>> getByUser(String userId) async =>
@@ -33,6 +38,7 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository {
@override @override
Future<ParseRule> create({ Future<ParseRule> create({
required String userId, required String userId,
required String packageName,
required ParseRuleKind kind, required ParseRuleKind kind,
required MatchMode matchMode, required MatchMode matchMode,
required String pattern, required String pattern,
@@ -47,6 +53,7 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository {
ParseRulesTableCompanion.insert( ParseRulesTableCompanion.insert(
id: id, id: id,
userId: userId, userId: userId,
packageName: Value(packageName),
kind: kind, kind: kind,
pattern: pattern, pattern: pattern,
matchMode: Value(matchMode), matchMode: Value(matchMode),
@@ -65,6 +72,7 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository {
Future<void> update(ParseRule rule) => _dao.updateRow( Future<void> update(ParseRule rule) => _dao.updateRow(
ParseRulesTableCompanion( ParseRulesTableCompanion(
id: Value(rule.id), id: Value(rule.id),
packageName: Value(rule.packageName),
kind: Value(rule.kind), kind: Value(rule.kind),
matchMode: Value(rule.matchMode), matchMode: Value(rule.matchMode),
pattern: Value(rule.pattern), pattern: Value(rule.pattern),
@@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../../../../core/database/app_database.dart'; import '../../../../core/database/app_database.dart';
import '../../../../core/database/converters/enum_converters.dart';
import '../../domain/entities/raw_message.dart'; import '../../domain/entities/raw_message.dart';
import '../../domain/enums.dart'; import '../../domain/enums.dart';
import '../../domain/repositories/raw_messages_repository.dart'; import '../../domain/repositories/raw_messages_repository.dart';
@@ -32,9 +33,19 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
Stream<List<RawMessage>> watchAll(String userId) => Stream<List<RawMessage>> watchAll(String userId) =>
_dao.watchAll(userId).map((rows) => rows.map((r) => r.toDomain()).toList()); _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 @override
Stream<int> watchInboxCount(String userId) => _dao.watchInboxCount(userId); 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 @override
Future<RawMessage?> findById(String id) async => Future<RawMessage?> findById(String id) async =>
(await _dao.findById(id))?.toDomain(); (await _dao.findById(id))?.toDomain();
@@ -56,6 +67,17 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
.map((r) => r.toDomain()) .map((r) => r.toDomain())
.toList(); .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 @override
Future<RawMessage> insertIncoming({ Future<RawMessage> insertIncoming({
required String userId, required String userId,
@@ -130,6 +152,39 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
Future<void> linkTransaction(String id, String transactionId) => Future<void> linkTransaction(String id, String transactionId) =>
_dao.linkTransaction(id, 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 @override
Future<void> incrementParseAttempts(String id) => Future<void> incrementParseAttempts(String id) =>
_dao.incrementParseAttempts(id); _dao.incrementParseAttempts(id);
@@ -42,10 +42,18 @@ class SourceAppsRepositoryImpl implements SourceAppsRepository {
return row!.toDomain(); return row!.toDomain();
} }
@override
Future<SourceApp?> findByPackageName(String userId, String packageName) async =>
(await _dao.findByPackageName(userId, packageName))?.toDomain();
@override @override
Future<void> setEnabled(String id, {required bool enabled}) => Future<void> setEnabled(String id, {required bool enabled}) =>
_dao.setEnabled(id, enabled: enabled); _dao.setEnabled(id, enabled: enabled);
@override
Future<void> setSelfMerchant(String id, {required bool value}) =>
_dao.setSelfMerchant(id, value: value);
@override @override
Future<void> deleteById(String id) => _dao.deleteById(id); 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({ const factory ParseRule({
required String id, required String id,
required String userId, required String userId,
/// Приложение-источник, к которому привязано правило. null — легаси
/// (глобальное правило, матчится в любом приложении).
String? packageName,
required ParseRuleKind kind, required ParseRuleKind kind,
required MatchMode matchMode, required MatchMode matchMode,
required String pattern, required String pattern,
@@ -42,6 +42,13 @@ abstract class RawMessage with _$RawMessage {
/// FK на transactions.id — заполняется когда статус applied. /// FK на transactions.id — заполняется когда статус applied.
String? transactionId, String? transactionId,
/// id второй половинки склеенного перевода (у primary — secondary и
/// наоборот). Null — вне пары.
String? pairedWithId,
/// Дедлайн ожидания пары (только для статуса waitingPair).
DateTime? pairDeadline,
required DateTime createdAt, required DateTime createdAt,
}) = _RawMessage; }) = _RawMessage;
} }
@@ -14,6 +14,9 @@ abstract class SourceApp with _$SourceApp {
required String packageName, required String packageName,
String? displayName, String? displayName,
@Default(true) bool enabled, @Default(true) bool enabled,
/// Уведомления не называют продавца: мерчант — само приложение (Ozon).
@Default(false) bool selfMerchant,
required DateTime createdAt, required DateTime createdAt,
}) = _SourceApp; }) = _SourceApp;
} }
@@ -15,6 +15,14 @@ enum RawMessageStatus {
applied, applied,
ignored, ignored,
failed, failed,
/// Распарсенная transfer-половинка скрыто ждёт вторую половинку пары
/// (окно до `pairDeadline`); по таймауту уходит в Inbox одиночкой.
waitingPair,
/// Вторичная половинка склеенного перевода: скрыта из Inbox, в журнале
/// показывается как «объединено в перевод».
paired,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -24,11 +32,6 @@ enum ParseRuleKind {
merchantToCategory, merchantToCategory,
senderToAccount, senderToAccount,
ignore, ignore,
/// Маркер «у мерчанта категория варьируется» (Ozon, маркетплейсы): не задаёт
/// действия, а подавляет предложение merchant→category-правила в Inbox.
/// Создаётся явным действием пользователя «Категории различаются».
mixedMerchant,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -7,8 +7,9 @@ abstract interface class ParseRulesRepository {
/// Все правила пользователя — для экрана «Правила парсинга». /// Все правила пользователя — для экрана «Правила парсинга».
Stream<List<ParseRule>> watchByUser(String userId); Stream<List<ParseRule>> watchByUser(String userId);
/// Только активные правила — для pipeline (rule_lookup). /// Активные правила приложения [packageName] (+ легаси-строки без
Future<List<ParseRule>> getEnabledByUser(String userId); /// packageName) — для pipeline (rule_lookup).
Future<List<ParseRule>> getEnabledForApp(String userId, String packageName);
Future<List<ParseRule>> getByUser(String userId); Future<List<ParseRule>> getByUser(String userId);
@@ -17,6 +18,7 @@ abstract interface class ParseRulesRepository {
/// Создаёт правило (активно сразу, weight=1). Возвращает сохранённую сущность. /// Создаёт правило (активно сразу, weight=1). Возвращает сохранённую сущность.
Future<ParseRule> create({ Future<ParseRule> create({
required String userId, required String userId,
required String packageName,
required ParseRuleKind kind, required ParseRuleKind kind,
required MatchMode matchMode, required MatchMode matchMode,
required String pattern, required String pattern,
@@ -1,3 +1,4 @@
import '../../../../core/database/converters/enum_converters.dart';
import '../entities/raw_message.dart'; import '../entities/raw_message.dart';
import '../enums.dart'; import '../enums.dart';
@@ -19,9 +20,18 @@ abstract interface class RawMessagesRepository {
/// Все сообщения пользователя (любой статус) — для «Журнала парсинга». /// Все сообщения пользователя (любой статус) — для «Журнала парсинга».
Stream<List<RawMessage>> watchAll(String userId); Stream<List<RawMessage>> watchAll(String userId);
/// Половинки переводов, ждущие пару (`waitingPair`) — триггер sweep
/// и таймера дедлайна в ParsingWorker.
Stream<List<RawMessage>> watchWaitingPair(String userId);
/// Реактивный счётчик Inbox — для бэджа на Home. /// Реактивный счётчик Inbox — для бэджа на Home.
Stream<int> watchInboxCount(String userId); Stream<int> watchInboxCount(String userId);
/// Частые категории подтверждённых транзакций из уведомлений приложения —
/// для чипов быстрого выбора на карточке Inbox (по частоте, затем свежести).
Stream<List<String>> watchTopCategoryIds(
String userId, String packageName, TransactionType type);
Future<RawMessage?> findById(String id); Future<RawMessage?> findById(String id);
/// Поиск дубля: совпадение [dedupHash] **и** `receivedAt` в пределах /// Поиск дубля: совпадение [dedupHash] **и** `receivedAt` в пределах
@@ -33,6 +43,15 @@ abstract interface class RawMessagesRepository {
/// Сообщения за период (для live-превью правил). /// Сообщения за период (для live-превью правил).
Future<List<RawMessage>> recentByUser(String userId, DateTime since); 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]** — /// dedup-хэшем уже есть **в пределах дедуп-окна по [receivedAt]** —
/// возвращает существующее, не создавая дубль. Тот же текст вне окна /// возвращает существующее, не создавая дубль. Тот же текст вне окна
@@ -67,6 +86,30 @@ abstract interface class RawMessagesRepository {
/// Привязка к созданной транзакции (status → applied). /// Привязка к созданной транзакции (status → applied).
Future<void> linkTransaction(String id, String transactionId); 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); Future<void> incrementParseAttempts(String id);
/// Сброс сообщения на повторную обработку (status → pending, попытки → 0). /// Сброс сообщения на повторную обработку (status → pending, попытки → 0).
@@ -9,6 +9,10 @@ abstract interface class SourceAppsRepository {
/// Стрим, чтобы фильтр и native-синк реагировали на изменения allowlist. /// Стрим, чтобы фильтр и native-синк реагировали на изменения allowlist.
Stream<Set<String>> watchEnabledPackages(String userId); Stream<Set<String>> watchEnabledPackages(String userId);
/// Приложение по packageName — снапшот для pipeline (allowlist + флаг
/// [SourceApp.selfMerchant]). null = приложение не добавлено.
Future<SourceApp?> findByPackageName(String userId, String packageName);
/// Добавляет приложение (включено по умолчанию). Возвращает сущность. /// Добавляет приложение (включено по умолчанию). Возвращает сущность.
/// Если строка с таким packageName уже есть — возвращает существующую. /// Если строка с таким packageName уже есть — возвращает существующую.
Future<SourceApp> add({ Future<SourceApp> add({
@@ -19,5 +23,8 @@ abstract interface class SourceAppsRepository {
Future<void> setEnabled(String id, {required bool enabled}); Future<void> setEnabled(String id, {required bool enabled});
/// Флаг «мерчант — само приложение» (Ozon, маркетплейсы).
Future<void> setSelfMerchant(String id, {required bool value});
Future<void> deleteById(String id); 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,31 +79,15 @@ class InboxScreen extends ConsumerWidget {
top: false, top: false,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 8), padding: const EdgeInsets.fromLTRB(12, 4, 12, 8),
child: Row( child: SizedBox(
children: [ width: double.infinity,
Expanded( child: FilledButton(
child: FilledButton( style: FilledButton.styleFrom(backgroundColor: p.accent),
style: FilledButton.styleFrom(backgroundColor: p.accent), onPressed: () => ref
onPressed: () => ref .read(inboxControllerProvider.notifier)
.read(inboxControllerProvider.notifier) .applyAll(userId),
.applyAll(userId), child: Text(l10n.inboxApplyAll),
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 || _LogFilter.waiting => m.status == RawMessageStatus.pendingAi ||
m.status == RawMessageStatus.pending || m.status == RawMessageStatus.pending ||
m.status == RawMessageStatus.parsing || m.status == RawMessageStatus.parsing ||
m.status == RawMessageStatus.parsed, m.status == RawMessageStatus.parsed ||
m.status == RawMessageStatus.waitingPair,
_LogFilter.inbox => m.status == RawMessageStatus.inbox || _LogFilter.inbox => m.status == RawMessageStatus.inbox ||
m.status == RawMessageStatus.parsedPartial, m.status == RawMessageStatus.parsedPartial,
_LogFilter.applied => m.status == RawMessageStatus.applied, _LogFilter.applied => m.status == RawMessageStatus.applied,
@@ -268,6 +269,30 @@ class _LogRowState extends ConsumerState<_LogRow> {
), ),
), ),
if (_expanded) _DetailPanel(message: message, bundle: bundle), 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) ...[ if (canRetry) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Align( Align(
@@ -593,5 +618,7 @@ class _StatusBadge extends StatelessWidget {
RawMessageStatus.applied => l10n.parsingStatusApplied, RawMessageStatus.applied => l10n.parsingStatusApplied,
RawMessageStatus.ignored => l10n.parsingStatusIgnored, RawMessageStatus.ignored => l10n.parsingStatusIgnored,
RawMessageStatus.failed => l10n.parsingStatusFailed, 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) ...[ if (defaultTargetPlatform == TargetPlatform.android) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
_Card( _Card(
@@ -13,12 +13,15 @@ import '../../../transactions/presentation/widgets/account_picker_sheet.dart';
import '../../../transactions/presentation/widgets/category_picker_sheet.dart'; import '../../../transactions/presentation/widgets/category_picker_sheet.dart';
import '../../../user/application/active_user_controller.dart'; import '../../../user/application/active_user_controller.dart';
import '../../application/rules_controller.dart'; import '../../application/rules_controller.dart';
import '../../application/source_apps_controller.dart';
import '../../domain/entities/parse_rule.dart'; import '../../domain/entities/parse_rule.dart';
import '../../domain/entities/source_app.dart';
import '../../domain/enums.dart'; import '../../domain/enums.dart';
/// Предзаполнение редактора при создании правила из Inbox. /// Предзаполнение редактора при создании правила из Inbox.
class RuleEditorPrefill { class RuleEditorPrefill {
const RuleEditorPrefill({ const RuleEditorPrefill({
required this.packageName,
required this.pattern, required this.pattern,
required this.merchantCanonical, required this.merchantCanonical,
this.categoryId, this.categoryId,
@@ -26,6 +29,7 @@ class RuleEditorPrefill {
this.matchMode = MatchMode.contains, this.matchMode = MatchMode.contains,
}); });
final String packageName;
final String pattern; final String pattern;
final String merchantCanonical; final String merchantCanonical;
final String? categoryId; final String? categoryId;
@@ -36,6 +40,7 @@ class RuleEditorPrefill {
/// Результат редактора в режиме compose (возвращается через pop в Inbox). /// Результат редактора в режиме compose (возвращается через pop в Inbox).
class RuleEditorResult { class RuleEditorResult {
const RuleEditorResult({ const RuleEditorResult({
required this.packageName,
required this.kind, required this.kind,
required this.matchMode, required this.matchMode,
required this.pattern, required this.pattern,
@@ -45,6 +50,7 @@ class RuleEditorResult {
this.priority = 0, this.priority = 0,
}); });
final String packageName;
final ParseRuleKind kind; final ParseRuleKind kind;
final MatchMode matchMode; final MatchMode matchMode;
final String pattern; final String pattern;
@@ -72,6 +78,7 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
final _merchantCtrl = TextEditingController(); final _merchantCtrl = TextEditingController();
ParseRuleKind _kind = ParseRuleKind.merchantToCategory; ParseRuleKind _kind = ParseRuleKind.merchantToCategory;
MatchMode _matchMode = MatchMode.contains; MatchMode _matchMode = MatchMode.contains;
String? _packageName;
String? _categoryId; String? _categoryId;
String? _accountId; String? _accountId;
int _priority = 0; int _priority = 0;
@@ -81,11 +88,16 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
bool get _isEdit => widget.ruleId != null; bool get _isEdit => widget.ruleId != null;
/// Приложение выбирается только при создании «с нуля» из списка правил;
/// из Inbox (prefill) и при правке оно зафиксировано (read-only).
bool get _appIsPickable => !_isEdit && widget.prefill == null;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final pf = widget.prefill; final pf = widget.prefill;
if (pf != null) { if (pf != null) {
_packageName = pf.packageName;
_patternCtrl.text = pf.pattern; _patternCtrl.text = pf.pattern;
_merchantCtrl.text = pf.merchantCanonical; _merchantCtrl.text = pf.merchantCanonical;
_matchMode = pf.matchMode; _matchMode = pf.matchMode;
@@ -104,6 +116,7 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
void _hydrateFromRule(ParseRule rule) { void _hydrateFromRule(ParseRule rule) {
if (_initialized) return; if (_initialized) return;
_packageName = rule.packageName;
_kind = rule.kind; _kind = rule.kind;
_patternCtrl.text = rule.pattern; _patternCtrl.text = rule.pattern;
_merchantCtrl.text = rule.merchantCanonical ?? ''; _merchantCtrl.text = rule.merchantCanonical ?? '';
@@ -116,11 +129,13 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
} }
bool get _isSender => _kind == ParseRuleKind.senderToAccount; bool get _isSender => _kind == ParseRuleKind.senderToAccount;
bool get _isMixed => _kind == ParseRuleKind.mixedMerchant;
/// Готовность к сохранению: для sender→account нужен счёт. /// Готовность к сохранению: нужны приложение и (для sender→account) счёт.
/// При правке легаси-правила без packageName сохранение не блокируем.
bool get _canSave => 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 { Future<void> _save(String userId, ParseRule? existing) async {
final pattern = _patternCtrl.text.trim(); final pattern = _patternCtrl.text.trim();
@@ -146,6 +161,7 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
} else { } else {
context.pop( context.pop(
RuleEditorResult( RuleEditorResult(
packageName: _packageName!,
kind: _kind, kind: _kind,
matchMode: _matchMode, matchMode: _matchMode,
pattern: pattern, pattern: pattern,
@@ -184,6 +200,15 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
final accountName = _accountId == null final accountName = _accountId == null
? null ? null
: accounts.where((a) => a.id == _accountId).firstOrNull?.name; : 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( return Scaffold(
backgroundColor: p.paper, backgroundColor: p.paper,
@@ -209,13 +234,59 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
// Выбор вида — только при создании из списка правил. Из Inbox // Выбор вида — только при создании из списка правил. Из Inbox
// (prefill) композим всегда merchant→category: senderToAccount там // (prefill) композим всегда merchant→category: senderToAccount там
// не применяется (inbox_controller создаёт правило этого вида). // не применяется (inbox_controller создаёт правило этого вида).
if (!_isEdit && widget.prefill == null) ...[ if (_appIsPickable) ...[
_KindSelector( _KindSelector(
kind: _kind, kind: _kind,
onChanged: (k) => setState(() => _kind = k), onChanged: (k) => setState(() => _kind = k),
), ),
const SizedBox(height: 16), 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, Text(l10n.ruleEditorIfContains,
style: TextStyle(fontSize: 13, color: p.ink2)), style: TextStyle(fontSize: 13, color: p.ink2)),
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -238,58 +309,53 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
onChanged: (m) => setState(() => _matchMode = m), onChanged: (m) => setState(() => _matchMode = m),
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
if (_isMixed) Text(l10n.ruleEditorThen,
Text(l10n.ruleKindMixedHint, style: TextStyle(fontSize: 13, color: p.ink2)),
style: TextStyle(fontSize: 13, color: p.ink2, height: 1.3)), const SizedBox(height: 8),
if (!_isMixed) ...[ if (!_isSender) ...[
Text(l10n.ruleEditorThen, _FieldRow(
style: TextStyle(fontSize: 13, color: p.ink2)), label: l10n.ruleEditorMerchant,
const SizedBox(height: 8), child: TextField(
if (!_isSender) ...[ controller: _merchantCtrl,
_FieldRow( textAlign: TextAlign.end,
label: l10n.ruleEditorMerchant, decoration: InputDecoration(
child: TextField( hintText: l10n.ruleEditorMerchantHint,
controller: _merchantCtrl, isDense: true,
textAlign: TextAlign.end, border: InputBorder.none,
decoration: InputDecoration(
hintText: l10n.ruleEditorMerchantHint,
isDense: true,
border: InputBorder.none,
),
), ),
), ),
_Divider(color: p.line), ),
_PickerRow( _Divider(color: p.line),
label: l10n.ruleEditorCategory,
value: categoryName ?? l10n.inboxNoCategory,
onTap: () async {
final id = await showCategoryPicker(
context,
userId: userId,
type: CategoryType.expense,
currentCategoryId: _categoryId,
);
if (id != null) setState(() => _categoryId = id);
},
),
_Divider(color: p.line),
],
_PickerRow( _PickerRow(
label: l10n.ruleEditorAccount, label: l10n.ruleEditorCategory,
value: accountName ?? value: categoryName ?? l10n.inboxNoCategory,
(_isSender
? l10n.ruleEditorAccountPick
: l10n.ruleEditorAccountUnchanged),
onTap: () async { onTap: () async {
final id = await showAccountPicker( final id = await showCategoryPicker(
context, context,
userId: userId, userId: userId,
currentAccountId: _accountId, type: CategoryType.expense,
currentCategoryId: _categoryId,
); );
if (id != null) setState(() => _accountId = id); if (id != null) setState(() => _categoryId = id);
}, },
), ),
_Divider(color: p.line),
], ],
_PickerRow(
label: l10n.ruleEditorAccount,
value: accountName ??
(_isSender
? l10n.ruleEditorAccountPick
: l10n.ruleEditorAccountUnchanged),
onTap: () async {
final id = await showAccountPicker(
context,
userId: userId,
currentAccountId: _accountId,
);
if (id != null) setState(() => _accountId = id);
},
),
const SizedBox(height: 12), const SizedBox(height: 12),
_AdvancedToggle( _AdvancedToggle(
expanded: _advanced, expanded: _advanced,
@@ -326,6 +392,7 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
userId: userId, userId: userId,
pattern: _patternCtrl.text, pattern: _patternCtrl.text,
mode: _matchMode, mode: _matchMode,
packageName: _packageName,
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
FilledButton( FilledButton(
@@ -347,20 +414,23 @@ class _MatchesPreview extends ConsumerWidget {
required this.userId, required this.userId,
required this.pattern, required this.pattern,
required this.mode, required this.mode,
required this.packageName,
}); });
final String userId; final String userId;
final String pattern; final String pattern;
final MatchMode mode; final MatchMode mode;
final String? packageName;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette; final p = context.palette;
final l10n = context.l10n; final l10n = context.l10n;
if (pattern.trim().isEmpty) return const SizedBox.shrink(); if (pattern.trim().isEmpty) return const SizedBox.shrink();
final matches = final matches = ref
ref.watch(ruleMatchPreviewProvider(userId, pattern, mode)).value ?? .watch(ruleMatchPreviewProvider(userId, pattern, mode, packageName))
const []; .value ??
const [];
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -11,7 +11,9 @@ import '../../../categories/application/categories_controller.dart';
import '../../../categories/domain/entities/category.dart'; import '../../../categories/domain/entities/category.dart';
import '../../../user/application/active_user_controller.dart'; import '../../../user/application/active_user_controller.dart';
import '../../application/rules_controller.dart'; import '../../application/rules_controller.dart';
import '../../application/source_apps_controller.dart';
import '../../domain/entities/parse_rule.dart'; import '../../domain/entities/parse_rule.dart';
import '../../domain/entities/source_app.dart';
import '../../domain/enums.dart'; import '../../domain/enums.dart';
import '../widgets/rule_card.dart'; import '../widgets/rule_card.dart';
import 'rule_editor_screen.dart'; import 'rule_editor_screen.dart';
@@ -56,6 +58,22 @@ class _RulesListScreenState extends ConsumerState<RulesListScreen> {
final accounts = final accounts =
ref.watch(accountsStreamProvider(userId)).value ?? const <Account>[]; ref.watch(accountsStreamProvider(userId)).value ?? const <Account>[];
final accountById = {for (final a in accounts) a.id: a}; 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( return Scaffold(
backgroundColor: p.paper, backgroundColor: p.paper,
@@ -105,20 +123,36 @@ class _RulesListScreenState extends ConsumerState<RulesListScreen> {
style: TextStyle(fontSize: 14, color: p.ink2)), style: TextStyle(fontSize: 14, color: p.ink2)),
), ),
) )
: ListView.separated( : ListView(
itemCount: rules.length, children: [
separatorBuilder: (_, _) => for (final header in sortedHeaders) ...[
Container(height: 1, color: p.line), Padding(
itemBuilder: (context, i) => RuleCard( padding:
rule: rules[i], const EdgeInsets.fromLTRB(16, 16, 16, 4),
categoryById: categoryById, child: Text(
accountById: accountById, header,
onTap: () => style: TextStyle(
context.push(AppRoutes.parsingRuleEdit(rules[i].id)), fontSize: 13,
onToggle: (enabled) => ref fontWeight: FontWeight.w600,
.read(rulesControllerProvider.notifier) color: p.ink2,
.setEnabled(rules[i].id, enabled: enabled), ),
), ),
),
for (final (i, rule) in groups[header]!.indexed) ...[
if (i > 0) Container(height: 1, color: p.line),
RuleCard(
rule: rule,
categoryById: categoryById,
accountById: accountById,
onTap: () => context
.push(AppRoutes.parsingRuleEdit(rule.id)),
onToggle: (enabled) => ref
.read(rulesControllerProvider.notifier)
.setEnabled(rule.id, enabled: enabled),
),
],
],
],
), ),
), ),
], ],
@@ -132,6 +166,7 @@ class _RulesListScreenState extends ConsumerState<RulesListScreen> {
if (result == null) return; if (result == null) return;
await ref.read(rulesControllerProvider.notifier).create( await ref.read(rulesControllerProvider.notifier).create(
userId: userId, userId: userId,
packageName: result.packageName,
kind: result.kind, kind: result.kind,
matchMode: result.matchMode, matchMode: result.matchMode,
pattern: result.pattern, pattern: result.pattern,
@@ -338,33 +338,66 @@ class _AddedAppTile extends ConsumerWidget {
onTap: () => context.push(AppRoutes.parsingAppBindings(app.packageName)), onTap: () => context.push(AppRoutes.parsingAppBindings(app.packageName)),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(14, 8, 8, 8), padding: const EdgeInsets.fromLTRB(14, 8, 8, 8),
child: Row( child: Column(
children: [ children: [
Icon(Icons.apps_outlined, size: 20, color: p.ink2), Row(
const SizedBox(width: 12), children: [
Expanded( Icon(Icons.apps_outlined, size: 20, color: p.ink2),
child: Column( const SizedBox(width: 12),
crossAxisAlignment: CrossAxisAlignment.start, Expanded(
children: [ child: Column(
Text(title, crossAxisAlignment: CrossAxisAlignment.start,
style: TextStyle(fontSize: 14, color: p.ink)), children: [
if (app.displayName != null) Text(title,
Text(app.packageName, style: TextStyle(fontSize: 14, color: p.ink)),
style: TextStyle(fontSize: 11, color: p.ink2)), if (app.displayName != null)
], Text(app.packageName,
style: TextStyle(fontSize: 11, color: p.ink2)),
],
),
),
Switch(
value: app.enabled,
activeThumbColor: p.accent,
onChanged: (v) => ctrl.setEnabled(app.id, enabled: v),
),
IconButton(
icon: Icon(Icons.delete_outline, size: 20, color: p.ink2),
tooltip: l10n.commonDelete,
onPressed: () => ctrl.delete(app.id),
),
Icon(Icons.chevron_right, size: 18, color: p.ink2),
],
),
// «Мерчант — само приложение» (Ozon): уведомления не называют
// продавца — категория выбирается вручную, правила не предлагаются.
Padding(
padding: const EdgeInsets.only(left: 32),
child: Tooltip(
message: l10n.sourceAppsSelfMerchantHint,
child: Row(
children: [
Icon(Icons.storefront_outlined, size: 16, color: p.ink2),
const SizedBox(width: 8),
Expanded(
child: Text(l10n.sourceAppsSelfMerchantLabel,
style: TextStyle(fontSize: 12, color: p.ink2)),
),
SizedBox(
height: 32,
width: 40,
child: Checkbox(
value: app.selfMerchant,
activeColor: p.accent,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onChanged: (v) =>
ctrl.setSelfMerchant(app.id, value: v ?? false),
),
),
],
),
), ),
), ),
Switch(
value: app.enabled,
activeThumbColor: p.accent,
onChanged: (v) => ctrl.setEnabled(app.id, enabled: v),
),
IconButton(
icon: Icon(Icons.delete_outline, size: 20, color: p.ink2),
tooltip: l10n.commonDelete,
onPressed: () => ctrl.delete(app.id),
),
Icon(Icons.chevron_right, size: 18, color: p.ink2),
], ],
), ),
), ),
@@ -6,8 +6,11 @@ import '../../../../app/l10n/l10n.dart';
import '../../../../app/router/app_routes.dart'; import '../../../../app/router/app_routes.dart';
import '../../../../app/theme/app_colors.dart'; import '../../../../app/theme/app_colors.dart';
import '../../../../core/database/converters/enum_converters.dart'; import '../../../../core/database/converters/enum_converters.dart';
import '../../../accounts/application/accounts_controller.dart';
import '../../../categories/domain/entities/category.dart'; import '../../../categories/domain/entities/category.dart';
import '../../../home/presentation/widgets/money_text.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 '../../../transactions/presentation/widgets/category_picker_sheet.dart';
import '../../application/inbox_controller.dart'; import '../../application/inbox_controller.dart';
import '../../data/parser/draft_codec.dart'; import '../../data/parser/draft_codec.dart';
@@ -18,7 +21,7 @@ import 'confidence_badge.dart';
import 'gate_check_labels.dart'; import 'gate_check_labels.dart';
/// Карточка Inbox (§12.2): мерчант, сумма, подсветка слабых полей и три /// Карточка Inbox (§12.2): мерчант, сумма, подсветка слабых полей и три
/// действия — «Создать правило», «Подтвердить разово», «Игнорировать». /// действия — «Создать правило», «Подтвердить», «Игнорировать».
class InboxCard extends ConsumerWidget { class InboxCard extends ConsumerWidget {
const InboxCard({ const InboxCard({
super.key, super.key,
@@ -50,10 +53,17 @@ class InboxCard extends ConsumerWidget {
? _FailedBody(message: message) ? _FailedBody(message: message)
: bundle == null : bundle == null
? _UnrecognizedBody(message: message) ? _UnrecognizedBody(message: message)
// pairedRawMessageId → склеенная пара «Перевод между счетами».
: bundle.pairedRawMessageId != null
? _TransferPairBody(
message: message,
userId: userId,
bundle: bundle,
)
// suggestion != null → знакомый мерчант: предлагаем правило. // suggestion != null → знакомый мерчант: предлагаем правило.
// suggestion == null → нет мерчанта (только сумма) ИЛИ мерчант // suggestion == null → нет мерчанта (только сумма) ИЛИ источник
// помечен как mixed → «подтвердить разово» без создания правила. // с selfMerchant → «Подтвердить» без создания правила.
: bundle.suggestion != null : bundle.suggestion != null
? _RecognizedBody( ? _RecognizedBody(
message: message, message: message,
userId: userId, userId: userId,
@@ -169,10 +179,11 @@ class _RecognizedBody extends ConsumerWidget {
), ),
], ],
const SizedBox(height: 12), const SizedBox(height: 12),
_CreateRuleButton( _SplitActionButton(
label: categoryName != null label: categoryName != null
? l10n.inboxCreateRule(merchant, categoryName) ? l10n.inboxCreateRule(merchant, categoryName)
: l10n.inboxCreateRuleNoCategory(merchant), : l10n.inboxCreateRuleNoCategory(merchant),
editTooltip: l10n.inboxEditRuleTooltip,
onTap: () => _createRule( onTap: () => _createRule(
context, context,
ref, ref,
@@ -194,16 +205,24 @@ class _RecognizedBody extends ConsumerWidget {
Expanded( Expanded(
child: _SecondaryButton( child: _SecondaryButton(
icon: Icons.check, icon: Icons.check,
label: l10n.inboxConfirmOnce, label: l10n.inboxConfirm,
onTap: accountId == null onTap: () async {
? null final acc = await _resolveAccount(context,
: () => ref.read(inboxControllerProvider.notifier).confirmOnce( userId: userId, accountId: accountId);
if (acc == null || !context.mounted) return;
await _runReporting(
context,
() => ref
.read(inboxControllerProvider.notifier)
.confirmOnce(
userId: userId, userId: userId,
message: message, message: message,
draft: draft, draft: draft,
accountId: accountId, accountId: acc,
categoryId: categoryId, categoryId: categoryId,
), ),
);
},
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -217,29 +236,6 @@ class _RecognizedBody extends ConsumerWidget {
), ),
], ],
), ),
// «Категории различаются» — мерчант вроде Ozon, у которого категория
// меняется от покупки к покупке: правило для него бессмысленно.
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () =>
ref.read(inboxControllerProvider.notifier).markMerchantMixed(
userId: userId,
message: message,
bundle: bundle,
merchantCanonical: merchant,
),
style: TextButton.styleFrom(
foregroundColor: p.ink2,
padding: const EdgeInsets.symmetric(vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: const Icon(Icons.shuffle, size: 14),
label: Text(l10n.inboxMarkMixed,
style: const TextStyle(fontSize: 12)),
),
),
], ],
); );
} }
@@ -251,22 +247,27 @@ class _RecognizedBody extends ConsumerWidget {
required String? categoryId, required String? categoryId,
required String merchant, required String merchant,
}) async { }) async {
if (accountId == null) return;
final draft = bundle.draft; final draft = bundle.draft;
if (categoryId == null) { if (categoryId == null) {
await _openEditor(context, ref, await _openEditor(context, ref,
accountId: accountId, categoryId: categoryId, merchant: merchant); accountId: accountId, categoryId: categoryId, merchant: merchant);
return; return;
} }
await ref.read(inboxControllerProvider.notifier).createRule( final acc =
userId: userId, await _resolveAccount(context, userId: userId, accountId: accountId);
message: message, if (acc == null || !context.mounted) return;
draft: draft, await _runReporting(
accountId: accountId, context,
categoryId: categoryId, () => ref.read(inboxControllerProvider.notifier).createRule(
merchantCanonical: merchant, userId: userId,
pattern: draft.merchantRaw ?? merchant, message: message,
); draft: draft,
accountId: acc,
categoryId: categoryId,
merchantCanonical: merchant,
pattern: draft.merchantRaw ?? merchant,
),
);
} }
Future<void> _openEditor( Future<void> _openEditor(
@@ -280,33 +281,65 @@ class _RecognizedBody extends ConsumerWidget {
final result = await context.push<RuleEditorResult?>( final result = await context.push<RuleEditorResult?>(
AppRoutes.parsingRuleNew, AppRoutes.parsingRuleNew,
extra: RuleEditorPrefill( extra: RuleEditorPrefill(
packageName: message.packageName,
pattern: draft.merchantRaw ?? merchant, pattern: draft.merchantRaw ?? merchant,
merchantCanonical: merchant, merchantCanonical: merchant,
categoryId: categoryId, categoryId: categoryId,
accountId: accountId, accountId: accountId,
), ),
); );
if (result == null) return; if (result == null || !context.mounted) return;
final acc = result.accountId ?? accountId; final acc = await _resolveAccount(context,
if (acc == null) return; userId: userId, accountId: result.accountId ?? accountId);
await ref.read(inboxControllerProvider.notifier).createRule( if (acc == null || !context.mounted) return;
userId: userId, await _runReporting(
message: message, context,
draft: draft, () => ref.read(inboxControllerProvider.notifier).createRule(
accountId: acc, userId: userId,
categoryId: result.categoryId, message: message,
merchantCanonical: draft: draft,
result.merchantCanonical.isEmpty ? merchant : result.merchantCanonical, accountId: acc,
pattern: result.pattern, categoryId: result.categoryId,
matchMode: result.matchMode, 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): уведомление без мерчанта (только /// Карточка без предложения правила (§9.1): уведомление без мерчанта (только
/// сумма) или мерчант помечен как mixed. Создавать merchant→category-правило /// сумма) или источник с selfMerchant (Ozon). Создавать merchant→category
/// тут нельзя/бессмысленно, поэтому единственное действие — выбрать категорию /// правило тут нельзя/бессмысленно: пользователь выбирает категорию
/// и «подтвердить разово». /// (обязательно) и подтверждает — либо уходит в полную форму карандашом.
class _ConfirmOnceBody extends ConsumerStatefulWidget { class _ConfirmOnceBody extends ConsumerStatefulWidget {
const _ConfirmOnceBody({ const _ConfirmOnceBody({
required this.message, required this.message,
@@ -358,10 +391,22 @@ class _ConfirmOnceBodyState extends ConsumerState<_ConfirmOnceBody> {
draft.merchantRaw ?? draft.merchantRaw ??
message.title ?? message.title ??
''; '';
final accountId = draft.accountId ?? widget.defaultAccountId;
final categoryName = final categoryName =
_categoryId != null ? widget.categoryById[_categoryId]?.name : null; _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 = final signed =
draft.type == TransactionType.expense ? -draft.amount : draft.amount; draft.type == TransactionType.expense ? -draft.amount : draft.amount;
final amountColor = final amountColor =
@@ -410,7 +455,22 @@ class _ConfirmOnceBodyState extends ConsumerState<_ConfirmOnceBody> {
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3)), style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3)),
], ],
const SizedBox(height: 12), 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( InkWell(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
onTap: () async { 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), const SizedBox(height: 10),
Row( Row(
children: [ children: [
Expanded( Expanded(
flex: 2, flex: 2,
child: FilledButton.icon( child: _SplitActionButton(
onPressed: accountId == null label: l10n.inboxConfirm,
? null editTooltip: l10n.inboxEditRuleTooltip,
: () => ref onTap: _categoryId == null ? null : _confirm,
.read(inboxControllerProvider.notifier) onEdit: _openFullForm,
.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),
), ),
), ),
const SizedBox(width: 8), 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 { 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.label,
required this.onTap, required this.onTap,
required this.onEdit, required this.onEdit,
required this.editTooltip,
}); });
final String label; final String label;
final VoidCallback onTap; final VoidCallback? onTap;
final VoidCallback onEdit; final VoidCallback onEdit;
final String editTooltip;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final p = context.palette; final p = context.palette;
final l10n = context.l10n;
return Material( return Material(
color: p.accent, color: p.accent,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -623,17 +995,20 @@ class _CreateRuleButton extends StatelessWidget {
child: InkWell( child: InkWell(
borderRadius: const BorderRadius.horizontal(left: Radius.circular(12)), borderRadius: const BorderRadius.horizontal(left: Radius.circular(12)),
onTap: onTap, onTap: onTap,
child: Padding( child: Opacity(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), opacity: onTap == null ? 0.55 : 1,
child: Text( child: Padding(
label, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
style: const TextStyle( child: Text(
fontSize: 14, label,
fontWeight: FontWeight.w600, style: const TextStyle(
color: Colors.white, fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
), ),
maxLines: 2,
overflow: TextOverflow.ellipsis,
), ),
), ),
), ),
@@ -643,7 +1018,7 @@ class _CreateRuleButton extends StatelessWidget {
borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)), borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)),
onTap: onEdit, onTap: onEdit,
child: Tooltip( child: Tooltip(
message: l10n.inboxEditRuleTooltip, message: editTooltip,
child: const Padding( child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 14, vertical: 12), padding: EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Icon(Icons.edit_outlined, size: 18, color: Colors.white), child: Icon(Icons.edit_outlined, size: 18, color: Colors.white),
@@ -33,7 +33,6 @@ class RuleCard extends StatelessWidget {
ParseRuleKind.merchantToCategory => Icons.storefront_outlined, ParseRuleKind.merchantToCategory => Icons.storefront_outlined,
ParseRuleKind.senderToAccount => Icons.credit_card_outlined, ParseRuleKind.senderToAccount => Icons.credit_card_outlined,
ParseRuleKind.ignore => Icons.block_outlined, ParseRuleKind.ignore => Icons.block_outlined,
ParseRuleKind.mixedMerchant => Icons.shuffle,
}; };
final categoryName = final categoryName =

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