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>
@@ -21,7 +21,10 @@
|
||||
"Bash(grep -E \"compileSdkVersion|minSdkVersion|targetSdkVersion\" \"C:\\\\\\\\Sanders\\\\\\\\FlutterSDK\\\\\\\\flutter\\\\\\\\packages\\\\\\\\flutter_tools\\\\\\\\gradle\\\\\\\\flutter.gradle\")",
|
||||
"Bash(grep -r \"compileSdk\\\\|minSdk\\\\|targetSdk\" \"C:\\\\\\\\Sanders\\\\\\\\FlutterSDK\\\\\\\\flutter\\\\\\\\packages\\\\\\\\flutter_tools\\\\\\\\gradle\")",
|
||||
"Bash(grep -E \"compileSdkVersion|minSdkVersion|targetSdkVersion\" \"C:\\\\\\\\Sanders\\\\\\\\FlutterSDK\\\\\\\\flutter\\\\\\\\packages\\\\\\\\flutter_tools\\\\\\\\gradle\\\\\\\\bin\\\\\\\\main\\\\\\\\FlutterExtension.kt\")",
|
||||
"Bash(xargs wc -l)"
|
||||
"Bash(xargs wc -l)",
|
||||
"Bash(git -C . worktree list)",
|
||||
"Bash(xargs grep -l \"class\")",
|
||||
"PowerShell(dart run build_runner build --delete-conflicting-outputs)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,10 +68,34 @@ app.*.map.json
|
||||
*.keystore
|
||||
|
||||
# iOS / macOS
|
||||
# NOTE: never blanket-ignore *.xcworkspace/ — contents.xcworkspacedata and
|
||||
# xcshareddata must be committed. Only per-user data (xcuserdata) is ignored.
|
||||
**/ios/**/*.mode1v3
|
||||
**/ios/**/*.mode2v3
|
||||
**/ios/**/*.moved-aside
|
||||
**/ios/**/*.pbxuser
|
||||
**/ios/**/*.perspectivev3
|
||||
**/ios/**/*sync/
|
||||
**/ios/**/.sconsign.dblite
|
||||
**/ios/**/.tags*
|
||||
**/ios/**/.vagrant/
|
||||
**/ios/**/DerivedData/
|
||||
**/ios/**/Icon?
|
||||
**/ios/**/profile
|
||||
**/ios/**/xcuserdata
|
||||
**/ios/.generated/
|
||||
**/ios/Flutter/.last_build_id
|
||||
**/ios/Flutter/App.framework
|
||||
**/ios/Flutter/Flutter.framework
|
||||
**/ios/Flutter/Flutter.podspec
|
||||
**/ios/**/*.xcworkspace/
|
||||
**/macos/**/*.xcworkspace/
|
||||
**/ios/Flutter/Generated.xcconfig
|
||||
**/ios/Flutter/ephemeral/
|
||||
**/ios/Flutter/app.flx
|
||||
**/ios/Flutter/app.zip
|
||||
**/ios/Flutter/flutter_assets/
|
||||
**/ios/Flutter/flutter_export_environment.sh
|
||||
**/ios/ServiceDefinitions.json
|
||||
**/ios/Runner/GeneratedPluginRegistrant.*
|
||||
Pods/
|
||||
.symlinks/
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ migration:
|
||||
- platform: root
|
||||
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
- platform: android
|
||||
- platform: ios
|
||||
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ lib/
|
||||
theme/app_colors.dart # Palette extension (paper/ink/line/accent/positive/negative)
|
||||
theme/theme_mode_controller.dart # @Riverpod(keepAlive) ThemeMode — in-memory for now
|
||||
core/
|
||||
database/app_database.dart # @DriftDatabase, schemaVersion=8
|
||||
database/app_database.dart # @DriftDatabase, schemaVersion=11
|
||||
database/tables/ # users / app_preferences / settings / accounts / categories / transactions
|
||||
database/daos/ # *_dao.dart with .watch*() methods
|
||||
database/converters/enum_converters.dart # TypeConverter + re-exports all enums (UI imports enums from here)
|
||||
@@ -82,7 +82,11 @@ lib/
|
||||
notification_parsing/ # SMS/notification → transaction parsing (rules + AI/DeepSeek).
|
||||
# domain/data/application/presentation; Drift tables + DAOs;
|
||||
# data/parser/ (dedup, confidence, decision_gate, ai_parser),
|
||||
# data/deepseek/ (client, prompts), ParsingWorker.
|
||||
# data/deepseek/ (client, prompts), data/native/ (notification
|
||||
# listener channel; Kotlin NotificationIngestService falls back to
|
||||
# tickerText when extras body is empty or a "содержимое скрыто"
|
||||
# stub — VTB puts the real text ONLY in ticker, no second post),
|
||||
# NotificationIngestWorker + ParsingWorker.
|
||||
# Screens: inbox, rules_list, rule_editor, parsing_settings, ai_consent, parsing_log
|
||||
profile/ # theme switcher screen
|
||||
shared/
|
||||
@@ -104,10 +108,17 @@ Every domain table has a `userId` FK → `users`.
|
||||
| `categories` | id, userId, name, type(enum), iconCode, colorValue, parentId(nullable), archived |
|
||||
| `transactions` | id, userId, accountId, categoryId(nullable), type(enum), amount(int), date, merchant(nullable, was `note`), extraInfo(nullable), transferToAccountId(nullable), obligation/impulse(habit enums, nullable), rawMessageId/autoApplied/appliedByRuleId (parsing), createdAt |
|
||||
|
||||
**Notification-parsing tables** (in `features/notification_parsing/data/drift/`): `raw_messages`,
|
||||
`parse_rules` (+`txType` — transaction type pinned at rule creation, checked by the gate),
|
||||
`rule_candidates`, `account_bindings` (+`isDefault` per-app default binding),
|
||||
`source_apps` (allowlist of monitored apps), `transfer_pairing_blocklist`. Their enums live
|
||||
**Notification-parsing tables** (in `features/notification_parsing/data/drift/`): `raw_messages`
|
||||
(+`diagnostics` — full notification-extras dump, captured only while the diagnostic-mode toggle
|
||||
in parsing settings is on), `parse_rules` (+`txType` — transaction type pinned at rule creation,
|
||||
checked by the gate; +`packageName` — rules are per-app: pipeline loads only rules of the
|
||||
message's source app via `getEnabledForApp` (NULL = legacy global rows still match anywhere);
|
||||
every create path must pass `packageName`),
|
||||
`rule_candidates` (NOT app-scoped — key is `userId+kind+rawValue`),
|
||||
`account_bindings` (+`isDefault` per-app default binding),
|
||||
`source_apps` (allowlist of monitored apps; +`selfMerchant` — "merchant is the app itself",
|
||||
e.g. Ozon: suppresses AI category prefill and the rule suggestion in Inbox),
|
||||
`transfer_pairing_blocklist`. Their enums live
|
||||
in `notification_parsing/domain/enums.dart`; converters in `.../data/drift/converters.dart` (both
|
||||
imported by `app_database.dart`).
|
||||
|
||||
@@ -142,11 +153,15 @@ Do not use hard-coded color constants in widgets.
|
||||
|
||||
Active profile is stored in `app_preferences` (key `active_user_id`), read via
|
||||
`activeUserControllerProvider`. `appRouter` has a `redirect` callback + `refreshListenable`
|
||||
on this provider: until a user exists, all routes redirect to `/onboarding`; after
|
||||
`usersController.createUser`, the redirect clears and `UserSeeder.seedForNewUser(userId)`
|
||||
seeds default accounts and categories. Demo transactions are **not** auto-seeded — they're
|
||||
added only via the manual "seed demo" button in `profile_screen` (temporary; remove once
|
||||
add-transaction UX is finished).
|
||||
on this provider: until a user exists, all routes redirect to `/onboarding`.
|
||||
`onboarding_screen.dart` is a **two-step flow** (name → first account) that does no DB
|
||||
writes until the final submit: it calls `usersController.createUser` (which runs
|
||||
`UserSeeder.seedForNewUser(userId)` — now seeds **default categories only**, no accounts),
|
||||
then creates the user's first account via `accountsController.createAccount` and marks it
|
||||
default, then `setActiveUser` (which clears the redirect → `/home`). Default **accounts** are
|
||||
no longer auto-seeded. Demo accounts + transactions are added only via the manual "seed
|
||||
demo" button in `profile_screen` (`seedDemoTransactionsForUser` self-heals missing
|
||||
accounts/categories; temporary — remove once add-transaction UX is finished).
|
||||
|
||||
Icon/color helpers that used to live in `_mock_data.dart` now live with their features:
|
||||
`features/categories/presentation/widgets/category_icon.dart` (`iconForCategory`,
|
||||
@@ -172,15 +187,17 @@ Icon/color helpers that used to live in `_mock_data.dart` now live with their fe
|
||||
- Access the container inside a widget test via
|
||||
`ProviderScope.containerOf(tester.element(find.byType(MyScreen)))` to manipulate
|
||||
notifier state after `pumpWidget`.
|
||||
- **`ParsingWorker` won't drain on `container.read` alone.** Its input
|
||||
`pendingMessagesProvider` is autoDispose and only subscribes to the Drift stream when it
|
||||
has a *direct* listener (in-app that's `HomeScreen`). In a bare `ProviderContainer` add
|
||||
`container.listen(pendingMessagesProvider(userId), (_, _) {}, fireImmediately: true)`
|
||||
alongside reading the worker, or pending messages stay stuck at `pending`.
|
||||
- **Integration tests hitting real OpenRouter** live in
|
||||
- **`ParsingWorker` inputs are direct repository stream subscriptions** (no intermediate
|
||||
autoDispose stream providers): `container.read(parsingWorkerProvider(userId))` alone is
|
||||
enough to drain in tests. Rationale: an internal `ref.listen` of a *paused* provider (a
|
||||
worker with no listeners of its own, i.e. bare-container tests) does not activate its
|
||||
autoDispose dependencies — Riverpod 3 pause semantics. Don't reintroduce stream
|
||||
providers as worker inputs.
|
||||
- **Integration tests hitting the real DeepSeek API** live in
|
||||
`test/features/notification_parsing/integration/`, tagged `@Tags(['integration'])`. Run
|
||||
with `flutter test ... --tags integration --dart-define=OPENROUTER_API_KEY=sk-or-...`
|
||||
(key never hardcoded; tests `skip:` when it's absent so default `flutter test` stays
|
||||
with `flutter test ... --tags integration --dart-define=DEEPSEEK_API_KEY=sk-...`
|
||||
(optional `--dart-define=DEEPSEEK_TEST_MODEL=...`, default `kDefaultAiModel` = `deepseek-chat`;
|
||||
key never hardcoded; tests `skip:` when it's absent so default `flutter test` stays
|
||||
green/offline). Override `aiKeyStoreProvider` with a fake key store + `isOnlineProvider`
|
||||
with `Stream.value(true)` (connectivity_plus has no binding under `flutter test`); assert
|
||||
the terminal `RawMessageStatus` and decode `draftJson` via `decodeDraftBundle`. Drafts for
|
||||
@@ -195,9 +212,9 @@ Icon/color helpers that used to live in `_mock_data.dart` now live with their fe
|
||||
(`build() => ThemeMode.dark`) and `app.dart` reads it, not settings. The `settings.themeMode`
|
||||
column exists but is unused. Mirror `AppLocaleController` (locale is already persisted).
|
||||
3. Fully remove demo-transaction code from `UserSeeder` once the add-transaction flow is
|
||||
solid. Auto-seeding on user creation is already gone (`seedForNewUser` only seeds accounts
|
||||
+ categories); what remains is the manual path — `seedDemoTransactionsForUser` /
|
||||
`_seedDemoTransactions` + the "seed demo" button in `profile_screen`.
|
||||
solid. `seedForNewUser` now seeds **only categories** (accounts are created by the user in
|
||||
onboarding); what remains to remove is the manual demo path — `seedDemoTransactionsForUser`
|
||||
/ `_seedDemoTransactions` / `_seedAccounts` + the "seed demo" button in `profile_screen`.
|
||||
4. `shared/formatters/` — empty; add intl money + date formatters; migrate `MoneyText` + day grouping.
|
||||
5. Expand test coverage (currently: transactions repo/controller/form, users controller, onboarding).
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="new_budget"
|
||||
android:label="Kitty finances"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
||||
@@ -27,11 +27,25 @@ class NotificationIngestService : NotificationListenerService() {
|
||||
val extras = notification.extras ?: return
|
||||
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()
|
||||
// big-text полнее обычного text — берём его при наличии.
|
||||
val body = (extras.getCharSequence(Notification.EXTRA_BIG_TEXT)
|
||||
val extrasBody = (extras.getCharSequence(Notification.EXTRA_BIG_TEXT)
|
||||
?: extras.getCharSequence(Notification.EXTRA_TEXT))
|
||||
?.toString()
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
// ВТБ прячет текст операции: в EXTRA_TEXT кладёт заглушку
|
||||
// «Конфиденциальная информация в уведомлении скрыта» (реальный контент
|
||||
// рисуется в custom RemoteViews и в extras не попадает), а полный текст
|
||||
// («Списание 2000р Счет*3891 …») доступен только в tickerText. Второго
|
||||
// поста с текстом, как у Т-Банка, ВТБ не шлёт — поэтому при пустом body
|
||||
// или заглушке падаем обратно на ticker.
|
||||
val ticker = notification.tickerText?.toString()?.trim().orEmpty()
|
||||
val body = if ((extrasBody.isEmpty() || isHiddenContentStub(extrasBody)) &&
|
||||
ticker.isNotEmpty()
|
||||
) {
|
||||
ticker
|
||||
} else {
|
||||
extrasBody
|
||||
}
|
||||
|
||||
val diagnosticMode = store.isDiagnosticMode()
|
||||
|
||||
@@ -51,6 +65,12 @@ class NotificationIngestService : NotificationListenerService() {
|
||||
LiveSink.tick()
|
||||
}
|
||||
|
||||
/** Заглушка «содержимое скрыто» вместо текста операции (ВТБ и т.п.). */
|
||||
private fun isHiddenContentStub(body: String): Boolean {
|
||||
val lower = body.lowercase()
|
||||
return lower.contains("конфиденциальн") && lower.contains("скрыт")
|
||||
}
|
||||
|
||||
/**
|
||||
* Человекочитаемый дамп уведомления для режима диагностики: `key`,
|
||||
* `postTime`, `visibility`, `flags`, `tickerText` и все поля `extras`.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="notif_listener_label">NewBudget — распознавание уведомлений</string>
|
||||
<string name="notif_listener_label">Kitty finances — распознавание уведомлений</string>
|
||||
</resources>
|
||||
|
||||
@@ -277,10 +277,11 @@ await verifier.migrateAndValidate(db, 8);
|
||||
Сейчас сидер всюду фейкается; реальный код не исполняется ни одним тестом, при этом
|
||||
от него зависит первый запуск приложения.
|
||||
|
||||
1. `seedForNewUser(userId)` → созданы дефолтные счета и категории (количества > 0;
|
||||
точные наборы не пиннить — они будут меняться), все с правильным `userId`.
|
||||
2. Один счёт помечен `isDefault` (если это контракт сидера — проверить по коду).
|
||||
3. Демо-транзакции созданы и ссылаются на посеянные счета/категории (FK-цепочка цела).
|
||||
1. `seedForNewUser(userId)` → созданы дефолтные **категории** (количество > 0; точные
|
||||
наборы не пиннить), все с правильным `userId`. Счета НЕ создаются и `isDefault` не
|
||||
выставляется — первый счёт создаёт пользователь на втором шаге онбординга.
|
||||
2. `seedDemoTransactionsForUser(userId)` (ручной путь из профиля) — создаёт недостающие
|
||||
счета/категории и демо-транзакции, ссылающиеся на них (FK-цепочка цела).
|
||||
Тест оформить так, чтобы при удалении демо-сида (пункт 2 бэклога CLAUDE.md)
|
||||
достаточно было удалить один блок ассертов.
|
||||
4. Повторный вызов для того же пользователя: пиннить фактическое поведение
|
||||
|
||||
@@ -147,7 +147,8 @@ unique `(userId, packageName)`. Присутствие строки = прило
|
||||
`accountTrusted=false` (multi-binding) → `inbox`; нет счёта → `inbox`.
|
||||
4. **Allowlist-тест** (`parsing_worker`): сообщение от пакета НЕ из включённых → `ignored`, AI не вызывается;
|
||||
от включённого банка без карты, но с глобальным дефолтом + правилом категории → авто-применение на дефолт.
|
||||
(Помнить про `container.listen(pendingMessagesProvider(userId), …)` — см. CLAUDE.md.)
|
||||
(Устарело: `pendingMessagesProvider` удалён — воркер подписан на Drift-стримы репозитория
|
||||
напрямую, в тестах достаточно `container.read(parsingWorkerProvider(userId))`.)
|
||||
5. Существующий пакет `test/features/notification_parsing/` — зелёный.
|
||||
6. Ручная проверка (`flutter run`): добавить приложение из каталога → включить → задать привязку к счёту
|
||||
и дефолт; убедиться, что уведомление от не-включённого пакета игнорируется; создать `senderToAccount`-правило.
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -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 "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
|
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
@@ -0,0 +1,6 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: FlutterSceneDelegate {
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"@@locale": "en",
|
||||
|
||||
"appTitle": "NewBudget",
|
||||
"appTitle": "Kitty finances",
|
||||
|
||||
"navHome": "Home",
|
||||
"navAnalytics": "Analytics",
|
||||
@@ -84,6 +84,8 @@
|
||||
"onboardingNameLabel": "Your name",
|
||||
"onboardingNameHint": "How should we address you?",
|
||||
"onboardingContinue": "Continue",
|
||||
"onboardingAccountTitle": "Your first account",
|
||||
"onboardingAccountSubtitle": "Add an account to track your money. You can add more later.",
|
||||
|
||||
"txNewTitle": "New transaction",
|
||||
"txEditTitle": "Edit transaction",
|
||||
@@ -208,6 +210,8 @@
|
||||
"parsingEnableLabel": "Recognize notifications",
|
||||
"parsingAutoApplyLabel": "Auto-add transactions",
|
||||
"parsingAutoApplyHint": "Add without confirmation when a rule exists, the amount is found in the message text and the account is trusted.",
|
||||
"parsingTransferPairingLabel": "Merge transfers between accounts",
|
||||
"parsingTransferPairingHint": "Two notifications about one transfer (debit + credit) are merged into a single transfer transaction pending confirmation.",
|
||||
"parsingDiagnosticModeLabel": "Diagnostic mode",
|
||||
"parsingDiagnosticModeHint": "Capture every notification from monitored apps (incl. empty/duplicate) with a full dump of its fields. Temporary — for debugging, adds noise to the parsing log.",
|
||||
"inboxWhyNotAuto": "Why not automatic: {reasons}",
|
||||
@@ -247,6 +251,9 @@
|
||||
"parsingStatusFailed": "Failed",
|
||||
"parsingStatusPendingAi": "Waiting for AI",
|
||||
"parsingStatusParsedPartial": "Partial",
|
||||
"parsingStatusWaitingPair": "Waiting for pair",
|
||||
"parsingStatusPaired": "Merged into transfer",
|
||||
"parsingUnmergeTransfer": "Split transfer",
|
||||
|
||||
"parsingLogOnline": "Online",
|
||||
"parsingLogOffline": "Offline",
|
||||
@@ -294,12 +301,15 @@
|
||||
"@inboxCreateRule": { "placeholders": { "merchant": { "type": "String" }, "category": { "type": "String" } } },
|
||||
"inboxCreateRuleNoCategory": "Create rule for “{merchant}”",
|
||||
"@inboxCreateRuleNoCategory": { "placeholders": { "merchant": { "type": "String" } } },
|
||||
"inboxConfirmOnce": "Confirm once",
|
||||
"inboxConfirm": "Confirm",
|
||||
"inboxMarkMixed": "Categories vary",
|
||||
"inboxCategoryRequiredHint": "Pick a category to confirm",
|
||||
"inboxIgnore": "Ignore",
|
||||
"inboxApplyAll": "Apply all",
|
||||
"inboxHideAll": "Hide all",
|
||||
"inboxTransferPairTitle": "Transfer between accounts",
|
||||
"inboxTransferFrom": "From account",
|
||||
"inboxTransferTo": "To account",
|
||||
"inboxTransferPickAccount": "Choose…",
|
||||
"inboxUnpair": "Split",
|
||||
"inboxUnrecognized": "Not recognized",
|
||||
"inboxParseErrorTitle": "Recognition error",
|
||||
"inboxRetry": "Try again",
|
||||
@@ -326,13 +336,16 @@
|
||||
"ruleEditorModeContains": "Contains",
|
||||
"ruleEditorModeExact": "Exact",
|
||||
"ruleEditorModeRegex": "Regex",
|
||||
"ruleKindMixedHint": "No category rule is suggested for this merchant: the category is picked manually for each transaction.",
|
||||
"ruleEditorThen": "Then it is:",
|
||||
"ruleEditorMerchant": "Merchant",
|
||||
"ruleEditorMerchantHint": "e.g. Wildberries",
|
||||
"ruleEditorCategory": "Category",
|
||||
"ruleEditorAccount": "Account",
|
||||
"ruleEditorAccountUnchanged": "— keep unchanged —",
|
||||
"ruleEditorApp": "App",
|
||||
"ruleEditorAppPick": "Select app",
|
||||
"ruleEditorNoApps": "Add a source app first",
|
||||
"rulesUnknownApp": "Unknown app",
|
||||
"ruleEditorAdvanced": "Advanced",
|
||||
"ruleEditorPriority": "Priority",
|
||||
"ruleEditorMatchesTitle": "Matches (last 30 days)",
|
||||
@@ -416,6 +429,8 @@
|
||||
"sourceAppsSearchHint": "Search",
|
||||
"sourceAppsPickerEmpty": "No apps found",
|
||||
"sourceAppsAlreadyAdded": "Already added",
|
||||
"sourceAppsSelfMerchantLabel": "Merchant is the app itself",
|
||||
"sourceAppsSelfMerchantHint": "Notifications never name the seller: pick the category manually, no rules are suggested",
|
||||
|
||||
"appBindingsTitle": "Account bindings",
|
||||
"appBindingsEmpty": "No bindings yet. Add one to map a card to an account.",
|
||||
|
||||
@@ -101,7 +101,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @appTitle.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'NewBudget'**
|
||||
/// **'Kitty finances'**
|
||||
String get appTitle;
|
||||
|
||||
/// No description provided for @navHome.
|
||||
@@ -308,6 +308,18 @@ abstract class AppLocalizations {
|
||||
/// **'Продолжить'**
|
||||
String get onboardingContinue;
|
||||
|
||||
/// No description provided for @onboardingAccountTitle.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Ваш первый счёт'**
|
||||
String get onboardingAccountTitle;
|
||||
|
||||
/// No description provided for @onboardingAccountSubtitle.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Добавьте счёт, чтобы учитывать деньги. Другие счета можно добавить позже.'**
|
||||
String get onboardingAccountSubtitle;
|
||||
|
||||
/// No description provided for @txNewTitle.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
@@ -920,6 +932,18 @@ abstract class AppLocalizations {
|
||||
/// **'Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.'**
|
||||
String get parsingAutoApplyHint;
|
||||
|
||||
/// No description provided for @parsingTransferPairingLabel.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Склеивать переводы между счетами'**
|
||||
String get parsingTransferPairingLabel;
|
||||
|
||||
/// No description provided for @parsingTransferPairingHint.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Два уведомления об одном переводе (списание + зачисление) объединяются в одну транзакцию-перевод на подтверждение.'**
|
||||
String get parsingTransferPairingHint;
|
||||
|
||||
/// No description provided for @parsingDiagnosticModeLabel.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
@@ -1136,6 +1160,24 @@ abstract class AppLocalizations {
|
||||
/// **'Частично'**
|
||||
String get parsingStatusParsedPartial;
|
||||
|
||||
/// No description provided for @parsingStatusWaitingPair.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Ждёт пару'**
|
||||
String get parsingStatusWaitingPair;
|
||||
|
||||
/// No description provided for @parsingStatusPaired.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Объединено в перевод'**
|
||||
String get parsingStatusPaired;
|
||||
|
||||
/// No description provided for @parsingUnmergeTransfer.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Расклеить перевод'**
|
||||
String get parsingUnmergeTransfer;
|
||||
|
||||
/// No description provided for @parsingLogOnline.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
@@ -1382,23 +1424,17 @@ abstract class AppLocalizations {
|
||||
/// **'Создать правило для «{merchant}»'**
|
||||
String inboxCreateRuleNoCategory(String merchant);
|
||||
|
||||
/// No description provided for @inboxConfirmOnce.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Подтвердить разово'**
|
||||
String get inboxConfirmOnce;
|
||||
|
||||
/// No description provided for @inboxConfirm.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Подтвердить'**
|
||||
String get inboxConfirm;
|
||||
|
||||
/// No description provided for @inboxMarkMixed.
|
||||
/// No description provided for @inboxCategoryRequiredHint.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Категории различаются'**
|
||||
String get inboxMarkMixed;
|
||||
/// **'Выберите категорию, чтобы подтвердить'**
|
||||
String get inboxCategoryRequiredHint;
|
||||
|
||||
/// No description provided for @inboxIgnore.
|
||||
///
|
||||
@@ -1412,11 +1448,35 @@ abstract class AppLocalizations {
|
||||
/// **'Учесть все'**
|
||||
String get inboxApplyAll;
|
||||
|
||||
/// No description provided for @inboxHideAll.
|
||||
/// No description provided for @inboxTransferPairTitle.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Скрыть разово'**
|
||||
String get inboxHideAll;
|
||||
/// **'Перевод между счетами'**
|
||||
String get inboxTransferPairTitle;
|
||||
|
||||
/// No description provided for @inboxTransferFrom.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Счёт списания'**
|
||||
String get inboxTransferFrom;
|
||||
|
||||
/// No description provided for @inboxTransferTo.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Счёт зачисления'**
|
||||
String get inboxTransferTo;
|
||||
|
||||
/// No description provided for @inboxTransferPickAccount.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Выбрать…'**
|
||||
String get inboxTransferPickAccount;
|
||||
|
||||
/// No description provided for @inboxUnpair.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Расклеить'**
|
||||
String get inboxUnpair;
|
||||
|
||||
/// No description provided for @inboxUnrecognized.
|
||||
///
|
||||
@@ -1556,12 +1616,6 @@ abstract class AppLocalizations {
|
||||
/// **'Regex'**
|
||||
String get ruleEditorModeRegex;
|
||||
|
||||
/// No description provided for @ruleKindMixedHint.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Для этого мерчанта правила-категории не предлагаются: категория выбирается вручную для каждой операции.'**
|
||||
String get ruleKindMixedHint;
|
||||
|
||||
/// No description provided for @ruleEditorThen.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
@@ -1598,6 +1652,30 @@ abstract class AppLocalizations {
|
||||
/// **'— не менять —'**
|
||||
String get ruleEditorAccountUnchanged;
|
||||
|
||||
/// No description provided for @ruleEditorApp.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Приложение'**
|
||||
String get ruleEditorApp;
|
||||
|
||||
/// No description provided for @ruleEditorAppPick.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Выберите приложение'**
|
||||
String get ruleEditorAppPick;
|
||||
|
||||
/// No description provided for @ruleEditorNoApps.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Сначала добавьте приложение-источник'**
|
||||
String get ruleEditorNoApps;
|
||||
|
||||
/// No description provided for @rulesUnknownApp.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Неизвестное приложение'**
|
||||
String get rulesUnknownApp;
|
||||
|
||||
/// No description provided for @ruleEditorAdvanced.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
@@ -1946,6 +2024,18 @@ abstract class AppLocalizations {
|
||||
/// **'Уже добавлено'**
|
||||
String get sourceAppsAlreadyAdded;
|
||||
|
||||
/// No description provided for @sourceAppsSelfMerchantLabel.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Мерчант — само приложение'**
|
||||
String get sourceAppsSelfMerchantLabel;
|
||||
|
||||
/// No description provided for @sourceAppsSelfMerchantHint.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Уведомления не называют продавца: категория выбирается вручную, правила не предлагаются'**
|
||||
String get sourceAppsSelfMerchantHint;
|
||||
|
||||
/// No description provided for @appBindingsTitle.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
|
||||
@@ -9,7 +9,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
AppLocalizationsEn([String locale = 'en']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appTitle => 'NewBudget';
|
||||
String get appTitle => 'Kitty finances';
|
||||
|
||||
@override
|
||||
String get navHome => 'Home';
|
||||
@@ -142,6 +142,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get onboardingContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get onboardingAccountTitle => 'Your first account';
|
||||
|
||||
@override
|
||||
String get onboardingAccountSubtitle =>
|
||||
'Add an account to track your money. You can add more later.';
|
||||
|
||||
@override
|
||||
String get txNewTitle => 'New transaction';
|
||||
|
||||
@@ -475,6 +482,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get parsingAutoApplyHint =>
|
||||
'Add without confirmation when a rule exists, the amount is found in the message text and the account is trusted.';
|
||||
|
||||
@override
|
||||
String get parsingTransferPairingLabel => 'Merge transfers between accounts';
|
||||
|
||||
@override
|
||||
String get parsingTransferPairingHint =>
|
||||
'Two notifications about one transfer (debit + credit) are merged into a single transfer transaction pending confirmation.';
|
||||
|
||||
@override
|
||||
String get parsingDiagnosticModeLabel => 'Diagnostic mode';
|
||||
|
||||
@@ -593,6 +607,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get parsingStatusParsedPartial => 'Partial';
|
||||
|
||||
@override
|
||||
String get parsingStatusWaitingPair => 'Waiting for pair';
|
||||
|
||||
@override
|
||||
String get parsingStatusPaired => 'Merged into transfer';
|
||||
|
||||
@override
|
||||
String get parsingUnmergeTransfer => 'Split transfer';
|
||||
|
||||
@override
|
||||
String get parsingLogOnline => 'Online';
|
||||
|
||||
@@ -724,14 +747,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return 'Create rule for “$merchant”';
|
||||
}
|
||||
|
||||
@override
|
||||
String get inboxConfirmOnce => 'Confirm once';
|
||||
|
||||
@override
|
||||
String get inboxConfirm => 'Confirm';
|
||||
|
||||
@override
|
||||
String get inboxMarkMixed => 'Categories vary';
|
||||
String get inboxCategoryRequiredHint => 'Pick a category to confirm';
|
||||
|
||||
@override
|
||||
String get inboxIgnore => 'Ignore';
|
||||
@@ -740,7 +760,19 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get inboxApplyAll => 'Apply all';
|
||||
|
||||
@override
|
||||
String get inboxHideAll => 'Hide all';
|
||||
String get inboxTransferPairTitle => 'Transfer between accounts';
|
||||
|
||||
@override
|
||||
String get inboxTransferFrom => 'From account';
|
||||
|
||||
@override
|
||||
String get inboxTransferTo => 'To account';
|
||||
|
||||
@override
|
||||
String get inboxTransferPickAccount => 'Choose…';
|
||||
|
||||
@override
|
||||
String get inboxUnpair => 'Split';
|
||||
|
||||
@override
|
||||
String get inboxUnrecognized => 'Not recognized';
|
||||
@@ -820,10 +852,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get ruleEditorModeRegex => 'Regex';
|
||||
|
||||
@override
|
||||
String get ruleKindMixedHint =>
|
||||
'No category rule is suggested for this merchant: the category is picked manually for each transaction.';
|
||||
|
||||
@override
|
||||
String get ruleEditorThen => 'Then it is:';
|
||||
|
||||
@@ -842,6 +870,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get ruleEditorAccountUnchanged => '— keep unchanged —';
|
||||
|
||||
@override
|
||||
String get ruleEditorApp => 'App';
|
||||
|
||||
@override
|
||||
String get ruleEditorAppPick => 'Select app';
|
||||
|
||||
@override
|
||||
String get ruleEditorNoApps => 'Add a source app first';
|
||||
|
||||
@override
|
||||
String get rulesUnknownApp => 'Unknown app';
|
||||
|
||||
@override
|
||||
String get ruleEditorAdvanced => 'Advanced';
|
||||
|
||||
@@ -1022,6 +1062,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get sourceAppsAlreadyAdded => 'Already added';
|
||||
|
||||
@override
|
||||
String get sourceAppsSelfMerchantLabel => 'Merchant is the app itself';
|
||||
|
||||
@override
|
||||
String get sourceAppsSelfMerchantHint =>
|
||||
'Notifications never name the seller: pick the category manually, no rules are suggested';
|
||||
|
||||
@override
|
||||
String get appBindingsTitle => 'Account bindings';
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
AppLocalizationsRu([String locale = 'ru']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appTitle => 'NewBudget';
|
||||
String get appTitle => 'Kitty finances';
|
||||
|
||||
@override
|
||||
String get navHome => 'Главная';
|
||||
@@ -148,6 +148,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get onboardingContinue => 'Продолжить';
|
||||
|
||||
@override
|
||||
String get onboardingAccountTitle => 'Ваш первый счёт';
|
||||
|
||||
@override
|
||||
String get onboardingAccountSubtitle =>
|
||||
'Добавьте счёт, чтобы учитывать деньги. Другие счета можно добавить позже.';
|
||||
|
||||
@override
|
||||
String get txNewTitle => 'Новая операция';
|
||||
|
||||
@@ -487,6 +494,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get parsingAutoApplyHint =>
|
||||
'Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.';
|
||||
|
||||
@override
|
||||
String get parsingTransferPairingLabel => 'Склеивать переводы между счетами';
|
||||
|
||||
@override
|
||||
String get parsingTransferPairingHint =>
|
||||
'Два уведомления об одном переводе (списание + зачисление) объединяются в одну транзакцию-перевод на подтверждение.';
|
||||
|
||||
@override
|
||||
String get parsingDiagnosticModeLabel => 'Режим диагностики';
|
||||
|
||||
@@ -604,6 +618,15 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get parsingStatusParsedPartial => 'Частично';
|
||||
|
||||
@override
|
||||
String get parsingStatusWaitingPair => 'Ждёт пару';
|
||||
|
||||
@override
|
||||
String get parsingStatusPaired => 'Объединено в перевод';
|
||||
|
||||
@override
|
||||
String get parsingUnmergeTransfer => 'Расклеить перевод';
|
||||
|
||||
@override
|
||||
String get parsingLogOnline => 'Онлайн';
|
||||
|
||||
@@ -734,14 +757,12 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return 'Создать правило для «$merchant»';
|
||||
}
|
||||
|
||||
@override
|
||||
String get inboxConfirmOnce => 'Подтвердить разово';
|
||||
|
||||
@override
|
||||
String get inboxConfirm => 'Подтвердить';
|
||||
|
||||
@override
|
||||
String get inboxMarkMixed => 'Категории различаются';
|
||||
String get inboxCategoryRequiredHint =>
|
||||
'Выберите категорию, чтобы подтвердить';
|
||||
|
||||
@override
|
||||
String get inboxIgnore => 'Игнорировать';
|
||||
@@ -750,7 +771,19 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get inboxApplyAll => 'Учесть все';
|
||||
|
||||
@override
|
||||
String get inboxHideAll => 'Скрыть разово';
|
||||
String get inboxTransferPairTitle => 'Перевод между счетами';
|
||||
|
||||
@override
|
||||
String get inboxTransferFrom => 'Счёт списания';
|
||||
|
||||
@override
|
||||
String get inboxTransferTo => 'Счёт зачисления';
|
||||
|
||||
@override
|
||||
String get inboxTransferPickAccount => 'Выбрать…';
|
||||
|
||||
@override
|
||||
String get inboxUnpair => 'Расклеить';
|
||||
|
||||
@override
|
||||
String get inboxUnrecognized => 'Не распознано';
|
||||
@@ -832,10 +865,6 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get ruleEditorModeRegex => 'Regex';
|
||||
|
||||
@override
|
||||
String get ruleKindMixedHint =>
|
||||
'Для этого мерчанта правила-категории не предлагаются: категория выбирается вручную для каждой операции.';
|
||||
|
||||
@override
|
||||
String get ruleEditorThen => 'То это:';
|
||||
|
||||
@@ -854,6 +883,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get ruleEditorAccountUnchanged => '— не менять —';
|
||||
|
||||
@override
|
||||
String get ruleEditorApp => 'Приложение';
|
||||
|
||||
@override
|
||||
String get ruleEditorAppPick => 'Выберите приложение';
|
||||
|
||||
@override
|
||||
String get ruleEditorNoApps => 'Сначала добавьте приложение-источник';
|
||||
|
||||
@override
|
||||
String get rulesUnknownApp => 'Неизвестное приложение';
|
||||
|
||||
@override
|
||||
String get ruleEditorAdvanced => 'Дополнительно';
|
||||
|
||||
@@ -1034,6 +1075,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get sourceAppsAlreadyAdded => 'Уже добавлено';
|
||||
|
||||
@override
|
||||
String get sourceAppsSelfMerchantLabel => 'Мерчант — само приложение';
|
||||
|
||||
@override
|
||||
String get sourceAppsSelfMerchantHint =>
|
||||
'Уведомления не называют продавца: категория выбирается вручную, правила не предлагаются';
|
||||
|
||||
@override
|
||||
String get appBindingsTitle => 'Привязки счетов';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"@@locale": "ru",
|
||||
|
||||
"appTitle": "NewBudget",
|
||||
"appTitle": "Kitty finances",
|
||||
|
||||
"navHome": "Главная",
|
||||
"navAnalytics": "Аналитика",
|
||||
@@ -84,6 +84,8 @@
|
||||
"onboardingNameLabel": "Ваше имя",
|
||||
"onboardingNameHint": "Например, Алекс",
|
||||
"onboardingContinue": "Продолжить",
|
||||
"onboardingAccountTitle": "Ваш первый счёт",
|
||||
"onboardingAccountSubtitle": "Добавьте счёт, чтобы учитывать деньги. Другие счета можно добавить позже.",
|
||||
|
||||
"txNewTitle": "Новая операция",
|
||||
"txEditTitle": "Редактировать операцию",
|
||||
@@ -208,6 +210,8 @@
|
||||
"parsingEnableLabel": "Распознавать уведомления",
|
||||
"parsingAutoApplyLabel": "Авто-добавление транзакций",
|
||||
"parsingAutoApplyHint": "Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.",
|
||||
"parsingTransferPairingLabel": "Склеивать переводы между счетами",
|
||||
"parsingTransferPairingHint": "Два уведомления об одном переводе (списание + зачисление) объединяются в одну транзакцию-перевод на подтверждение.",
|
||||
"parsingDiagnosticModeLabel": "Режим диагностики",
|
||||
"parsingDiagnosticModeHint": "Ловить каждое уведомление от мониторимых приложений (включая пустые/повторные) с полным дампом всех полей. Временно — для отладки, добавляет шум в журнал парсинга.",
|
||||
"inboxWhyNotAuto": "Почему не автоматически: {reasons}",
|
||||
@@ -247,6 +251,9 @@
|
||||
"parsingStatusFailed": "Ошибка",
|
||||
"parsingStatusPendingAi": "Ждёт AI",
|
||||
"parsingStatusParsedPartial": "Частично",
|
||||
"parsingStatusWaitingPair": "Ждёт пару",
|
||||
"parsingStatusPaired": "Объединено в перевод",
|
||||
"parsingUnmergeTransfer": "Расклеить перевод",
|
||||
|
||||
"parsingLogOnline": "Онлайн",
|
||||
"parsingLogOffline": "Офлайн",
|
||||
@@ -294,12 +301,15 @@
|
||||
"@inboxCreateRule": { "placeholders": { "merchant": { "type": "String" }, "category": { "type": "String" } } },
|
||||
"inboxCreateRuleNoCategory": "Создать правило для «{merchant}»",
|
||||
"@inboxCreateRuleNoCategory": { "placeholders": { "merchant": { "type": "String" } } },
|
||||
"inboxConfirmOnce": "Подтвердить разово",
|
||||
"inboxConfirm": "Подтвердить",
|
||||
"inboxMarkMixed": "Категории различаются",
|
||||
"inboxCategoryRequiredHint": "Выберите категорию, чтобы подтвердить",
|
||||
"inboxIgnore": "Игнорировать",
|
||||
"inboxApplyAll": "Учесть все",
|
||||
"inboxHideAll": "Скрыть разово",
|
||||
"inboxTransferPairTitle": "Перевод между счетами",
|
||||
"inboxTransferFrom": "Счёт списания",
|
||||
"inboxTransferTo": "Счёт зачисления",
|
||||
"inboxTransferPickAccount": "Выбрать…",
|
||||
"inboxUnpair": "Расклеить",
|
||||
"inboxUnrecognized": "Не распознано",
|
||||
"inboxParseErrorTitle": "Ошибка распознавания",
|
||||
"inboxRetry": "Попробовать снова",
|
||||
@@ -326,13 +336,16 @@
|
||||
"ruleEditorModeContains": "Содержит",
|
||||
"ruleEditorModeExact": "Точно",
|
||||
"ruleEditorModeRegex": "Regex",
|
||||
"ruleKindMixedHint": "Для этого мерчанта правила-категории не предлагаются: категория выбирается вручную для каждой операции.",
|
||||
"ruleEditorThen": "То это:",
|
||||
"ruleEditorMerchant": "Мерчант",
|
||||
"ruleEditorMerchantHint": "Напр. Wildberries",
|
||||
"ruleEditorCategory": "Категория",
|
||||
"ruleEditorAccount": "Счёт",
|
||||
"ruleEditorAccountUnchanged": "— не менять —",
|
||||
"ruleEditorApp": "Приложение",
|
||||
"ruleEditorAppPick": "Выберите приложение",
|
||||
"ruleEditorNoApps": "Сначала добавьте приложение-источник",
|
||||
"rulesUnknownApp": "Неизвестное приложение",
|
||||
"ruleEditorAdvanced": "Дополнительно",
|
||||
"ruleEditorPriority": "Приоритет",
|
||||
"ruleEditorMatchesTitle": "Совпадает с (последние 30 дней)",
|
||||
@@ -416,6 +429,8 @@
|
||||
"sourceAppsSearchHint": "Поиск",
|
||||
"sourceAppsPickerEmpty": "Приложения не найдены",
|
||||
"sourceAppsAlreadyAdded": "Уже добавлено",
|
||||
"sourceAppsSelfMerchantLabel": "Мерчант — само приложение",
|
||||
"sourceAppsSelfMerchantHint": "Уведомления не называют продавца: категория выбирается вручную, правила не предлагаются",
|
||||
|
||||
"appBindingsTitle": "Привязки счетов",
|
||||
"appBindingsEmpty": "Пока нет привязок. Добавьте, чтобы связать карту со счётом.",
|
||||
|
||||
@@ -17,7 +17,7 @@ class NewBudgetApp extends ConsumerWidget {
|
||||
final locale = ref.watch(appLocaleControllerProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'NewBudget',
|
||||
title: 'Kitty finances',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
|
||||
@@ -59,9 +59,11 @@ GoRouter appRouter(Ref ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.transactionNew,
|
||||
pageBuilder: (context, state) => _slideUpPage<void>(
|
||||
pageBuilder: (context, state) => _slideUpPage<String?>(
|
||||
state,
|
||||
const TransactionFormScreen(),
|
||||
TransactionFormScreen(
|
||||
prefill: state.extra as TransactionFormPrefill?,
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
|
||||
@@ -28,6 +28,7 @@ import '../../features/notification_parsing/data/drift/daos/parse_rules_dao.dart
|
||||
import '../../features/notification_parsing/data/drift/daos/rule_candidates_dao.dart';
|
||||
import '../../features/notification_parsing/data/drift/daos/account_bindings_dao.dart';
|
||||
import '../../features/notification_parsing/data/drift/daos/source_apps_dao.dart';
|
||||
import '../../features/notification_parsing/data/drift/daos/transfer_pairing_blocklist_dao.dart';
|
||||
|
||||
part 'app_database.g.dart';
|
||||
|
||||
@@ -59,6 +60,7 @@ part 'app_database.g.dart';
|
||||
RuleCandidatesDao,
|
||||
AccountBindingsDao,
|
||||
SourceAppsDao,
|
||||
TransferPairingBlocklistDao,
|
||||
],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
@@ -68,74 +70,13 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase.forTesting(super.executor);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 9;
|
||||
int get schemaVersion => 1;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
onCreate: (m) async {
|
||||
await m.createAll();
|
||||
},
|
||||
onUpgrade: (m, from, to) async {
|
||||
if (from < 2) {
|
||||
// v1 → v2: PK сменились с int autoIncrement на UUID text.
|
||||
await m.drop(transactionsTable);
|
||||
await m.drop(categoriesTable);
|
||||
await m.drop(accountsTable);
|
||||
await m.drop(settingsTable);
|
||||
await m.drop(usersTable);
|
||||
await m.createAll();
|
||||
}
|
||||
if (from < 3) {
|
||||
// v2 → v3: добавлено поле extra_info в transactions.
|
||||
await m.addColumn(transactionsTable, transactionsTable.extraInfo);
|
||||
}
|
||||
if (from < 4) {
|
||||
// v3 → v4: добавлено поле is_default в accounts.
|
||||
await m.addColumn(accountsTable, accountsTable.isDefault);
|
||||
}
|
||||
if (from < 5) {
|
||||
// v4 → v5: notification-parsing schema.
|
||||
//
|
||||
// Transactions: note → merchant + новые поля парсинга.
|
||||
await m.renameColumn(
|
||||
transactionsTable, 'note', transactionsTable.merchant);
|
||||
await m.addColumn(
|
||||
transactionsTable, transactionsTable.rawMessageId);
|
||||
await m.addColumn(
|
||||
transactionsTable, transactionsTable.autoApplied);
|
||||
await m.addColumn(
|
||||
transactionsTable, transactionsTable.appliedByRuleId);
|
||||
// Новые таблицы:
|
||||
await m.createTable(rawMessagesTable);
|
||||
await m.createTable(parseRulesTable);
|
||||
await m.createTable(ruleCandidatesTable);
|
||||
await m.createTable(accountBindingsTable);
|
||||
await m.createTable(transferPairingBlocklistTable);
|
||||
}
|
||||
if (from < 6) {
|
||||
// v5 → v6: habit-tracking поля.
|
||||
await m.addColumn(
|
||||
transactionsTable, transactionsTable.obligation);
|
||||
await m.addColumn(transactionsTable, transactionsTable.impulse);
|
||||
await m.addColumn(
|
||||
settingsTable, settingsTable.habitTrackingEnabled);
|
||||
}
|
||||
if (from < 7) {
|
||||
// v6 → v7: умолчательные привязки + allowlist приложений-источников.
|
||||
await m.addColumn(
|
||||
accountBindingsTable, accountBindingsTable.isDefault);
|
||||
await m.createTable(sourceAppsTable);
|
||||
}
|
||||
if (from < 8) {
|
||||
// v7 → v8: тип операции фиксируется в правиле (для gate-проверки
|
||||
// typeMatchesRule).
|
||||
await m.addColumn(parseRulesTable, parseRulesTable.txType);
|
||||
}
|
||||
if (from < 9) {
|
||||
// v8 → v9: дамп нативного уведомления для режима диагностики.
|
||||
await m.addColumn(rawMessagesTable, rawMessagesTable.diagnostics);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
static QueryExecutor _openConnection() {
|
||||
|
||||
@@ -11,28 +11,31 @@ part 'habit_analysis_providers.g.dart';
|
||||
// Состояние фильтров (autoDispose)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Фильтр по импульсивности: `null` = «Все».
|
||||
/// Фильтр по импульсивности (главная шкала каскада): `null` = «Все».
|
||||
@riverpod
|
||||
class HabitImpulseFilter extends _$HabitImpulseFilter {
|
||||
@override
|
||||
SpendingImpulse? build() => null;
|
||||
|
||||
void select(SpendingImpulse? value) => state = value;
|
||||
void select(SpendingImpulse? value) {
|
||||
// У «Обязательно» импульсивности не бывает (инвариант формы транзакции) —
|
||||
// активный импульс-фильтр несовместим с этим значением обязательности.
|
||||
if (value != null &&
|
||||
ref.read(habitObligationFilterProvider) ==
|
||||
SpendingObligation.required) {
|
||||
ref.read(habitObligationFilterProvider.notifier).select(null);
|
||||
}
|
||||
state = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// Фильтр по обязательности (мульти-выбор). Пустое множество = «Все».
|
||||
/// Фильтр по обязательности (вторичная шкала): `null` = «Все».
|
||||
@riverpod
|
||||
class HabitObligationFilter extends _$HabitObligationFilter {
|
||||
@override
|
||||
Set<SpendingObligation> build() => const {};
|
||||
SpendingObligation? build() => null;
|
||||
|
||||
void toggle(SpendingObligation value) {
|
||||
final next = Set<SpendingObligation>.from(state);
|
||||
if (!next.add(value)) next.remove(value);
|
||||
state = next;
|
||||
}
|
||||
|
||||
void clear() => state = const {};
|
||||
void select(SpendingObligation? value) => state = value;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -71,12 +74,16 @@ Map<SpendingImpulse?, int> habitSumByImpulse(Ref ref, String userId) {
|
||||
return map;
|
||||
}
|
||||
|
||||
/// Суммы расходов по обязательности (включая ключ `null` = «без оценки»).
|
||||
/// Суммы расходов по обязательности (включая ключ `null` = «без оценки»)
|
||||
/// внутри выборки активного импульс-фильтра: вторая строка каскада
|
||||
/// показывает разбивку выбранного импульс-сегмента.
|
||||
@riverpod
|
||||
Map<SpendingObligation?, int> habitSumByObligation(Ref ref, String userId) {
|
||||
final txs = ref.watch(habitMonthTransactionsProvider(userId));
|
||||
final impulse = ref.watch(habitImpulseFilterProvider);
|
||||
final map = <SpendingObligation?, int>{};
|
||||
for (final t in txs) {
|
||||
if (impulse != null && t.impulse != impulse) continue;
|
||||
map[t.obligation] = (map[t.obligation] ?? 0) + t.amount;
|
||||
}
|
||||
return map;
|
||||
@@ -87,14 +94,11 @@ Map<SpendingObligation?, int> habitSumByObligation(Ref ref, String userId) {
|
||||
List<Transaction> habitFilteredTransactions(Ref ref, String userId) {
|
||||
final txs = ref.watch(habitMonthTransactionsProvider(userId));
|
||||
final impulse = ref.watch(habitImpulseFilterProvider);
|
||||
final obligations = ref.watch(habitObligationFilterProvider);
|
||||
final obligation = ref.watch(habitObligationFilterProvider);
|
||||
|
||||
return txs.where((t) {
|
||||
if (impulse != null && t.impulse != impulse) return false;
|
||||
if (obligations.isNotEmpty &&
|
||||
(t.obligation == null || !obligations.contains(t.obligation))) {
|
||||
return false;
|
||||
}
|
||||
if (obligation != null && t.obligation != obligation) return false;
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@@ -110,10 +110,15 @@ class _HeaderBar extends ConsumerWidget {
|
||||
final monthTitle = '$monthCap ${month.year}';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
|
||||
padding: const EdgeInsets.fromLTRB(4, 8, 16, 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: Icon(Icons.close, color: p.ink),
|
||||
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.habitAnalysisTitle,
|
||||
@@ -197,6 +202,14 @@ class _FilterBlock extends ConsumerWidget {
|
||||
|
||||
final totalImpulse =
|
||||
impulseSums.values.fold<int>(0, (s, v) => s + v);
|
||||
final totalObligation =
|
||||
obligationSums.values.fold<int>(0, (s, v) => s + v);
|
||||
// «Обязательно» несовместимо с импульс-фильтром (инвариант формы).
|
||||
final obligationValues = impulseFilter == null
|
||||
? SpendingObligation.values
|
||||
: SpendingObligation.values
|
||||
.where((o) => o != SpendingObligation.required)
|
||||
.toList();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
@@ -242,25 +255,39 @@ class _FilterBlock extends ConsumerWidget {
|
||||
const SizedBox(height: 16),
|
||||
_ScaleCaption(l10n.habitScaleObligationCaps),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final o in SpendingObligation.values) ...[
|
||||
Expanded(
|
||||
child: _ObligationChip(
|
||||
title: l10n.habitFilterAll,
|
||||
sumMinor: totalObligation,
|
||||
selected: obligationFilter == null,
|
||||
onTap: () => ref
|
||||
.read(habitObligationFilterProvider.notifier)
|
||||
.select(null),
|
||||
),
|
||||
),
|
||||
for (final o in obligationValues) ...[
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _ObligationChip(
|
||||
title: _obligationFilterLabel(l10n, o),
|
||||
sumMinor: obligationSums[o] ?? 0,
|
||||
dotColor: _dotColor(hc, o),
|
||||
selected: obligationFilter.contains(o),
|
||||
selected: obligationFilter == o,
|
||||
onTap: () => ref
|
||||
.read(habitObligationFilterProvider.notifier)
|
||||
.toggle(o),
|
||||
.select(obligationFilter == o ? null : o),
|
||||
),
|
||||
),
|
||||
if (o != SpendingObligation.values.last)
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Divider(height: 1, color: p.line),
|
||||
],
|
||||
@@ -360,14 +387,14 @@ class _ObligationChip extends StatelessWidget {
|
||||
const _ObligationChip({
|
||||
required this.title,
|
||||
required this.sumMinor,
|
||||
required this.dotColor,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.dotColor,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final int sumMinor;
|
||||
final Color dotColor;
|
||||
final Color? dotColor;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@@ -392,6 +419,7 @@ class _ObligationChip extends StatelessWidget {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (dotColor != null) ...[
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
@@ -399,6 +427,7 @@ class _ObligationChip extends StatelessWidget {
|
||||
BoxDecoration(color: dotColor, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../../core/database/converters/enum_converters.dart';
|
||||
import '../../accounts/application/account_providers.dart';
|
||||
import '../../transactions/application/transaction_providers.dart';
|
||||
import '../../transactions/application/transactions_controller.dart';
|
||||
import '../data/parser/draft_codec.dart';
|
||||
import '../data/parser/rule_lookup.dart';
|
||||
import '../data/parser/transfer_pair_matcher.dart';
|
||||
import '../domain/entities/parse_draft.dart';
|
||||
import '../domain/entities/raw_message.dart';
|
||||
import '../domain/enums.dart';
|
||||
@@ -26,6 +28,35 @@ Stream<int> inboxCount(Ref ref, String userId) =>
|
||||
Stream<List<RawMessage>> parsingLog(Ref ref, String userId) =>
|
||||
ref.watch(rawMessagesRepositoryProvider).watchAll(userId);
|
||||
|
||||
/// Частые категории подтверждённых транзакций этого приложения — чипы
|
||||
/// быстрого выбора на карточке (источники с selfMerchant: Ozon и т.п.).
|
||||
@riverpod
|
||||
Stream<List<String>> topConfirmCategories(
|
||||
Ref ref,
|
||||
String userId,
|
||||
String packageName,
|
||||
TransactionType type,
|
||||
) =>
|
||||
ref
|
||||
.watch(rawMessagesRepositoryProvider)
|
||||
.watchTopCategoryIds(userId, packageName, type);
|
||||
|
||||
/// Вторая половинка склеенной пары — merged-карточка показывает её
|
||||
/// приложение-источник.
|
||||
@riverpod
|
||||
Future<RawMessage?> pairedRawMessage(Ref ref, String id) =>
|
||||
ref.watch(rawMessagesRepositoryProvider).findById(id);
|
||||
|
||||
/// Отображаемое имя приложения-источника (fallback — packageName).
|
||||
@riverpod
|
||||
Future<String> sourceAppLabel(
|
||||
Ref ref, String userId, String packageName) async {
|
||||
final app = await ref
|
||||
.watch(sourceAppsRepositoryProvider)
|
||||
.findByPackageName(userId, packageName);
|
||||
return app?.displayName ?? packageName;
|
||||
}
|
||||
|
||||
/// Действия над карточками Inbox (§9.1). Все мутации — явные тапы пользователя.
|
||||
@Riverpod(keepAlive: true)
|
||||
class InboxController extends _$InboxController {
|
||||
@@ -61,6 +92,7 @@ class InboxController extends _$InboxController {
|
||||
|
||||
await ref.read(parseRulesRepositoryProvider).create(
|
||||
userId: userId,
|
||||
packageName: message.packageName,
|
||||
kind: ParseRuleKind.merchantToCategory,
|
||||
matchMode: matchMode,
|
||||
pattern: pattern,
|
||||
@@ -125,60 +157,12 @@ class InboxController extends _$InboxController {
|
||||
}
|
||||
}
|
||||
|
||||
/// «Категории различаются»: помечает мерчанта как mixed (§9.1) — у него
|
||||
/// категория варьируется (Ozon, маркетплейсы), поэтому merchant→category
|
||||
/// правило для него бессмысленно. Создаёт правило-маркер `mixedMerchant`,
|
||||
/// удаляет уже созданное merchant→category правило и наблюдённого кандидата,
|
||||
/// затем перезаписывает кеш `draftJson` без suggestion — карточка тут же
|
||||
/// переключается на «подтвердить разово».
|
||||
Future<void> markMerchantMixed({
|
||||
required String userId,
|
||||
required RawMessage message,
|
||||
required DraftBundle bundle,
|
||||
required String merchantCanonical,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final draft = bundle.draft;
|
||||
|
||||
await ref.read(parseRulesRepositoryProvider).create(
|
||||
userId: userId,
|
||||
kind: ParseRuleKind.mixedMerchant,
|
||||
matchMode: MatchMode.contains,
|
||||
pattern: merchantCanonical,
|
||||
);
|
||||
|
||||
// Снести уже существующее merchant→category правило для этого мерчанта,
|
||||
// чтобы оно перестало авто-применять одну категорию.
|
||||
final rules = await ref.read(parseRulesRepositoryProvider).getByUser(userId);
|
||||
final existing = findMerchantRule(rules,
|
||||
body: message.body, merchantRaw: draft.merchantRaw);
|
||||
if (existing != null) {
|
||||
await ref.read(parseRulesRepositoryProvider).deleteById(existing.id);
|
||||
}
|
||||
if (draft.merchantRaw != null) {
|
||||
await ref
|
||||
.read(ruleCandidatesRepositoryProvider)
|
||||
.deleteByRawValue(userId, draft.merchantRaw!);
|
||||
}
|
||||
|
||||
await ref.read(rawMessagesRepositoryProvider).updateAfterParse(
|
||||
id: message.id,
|
||||
status: RawMessageStatus.inbox,
|
||||
draftJson:
|
||||
encodeDraftBundle(draft, null, failedChecks: bundle.failedChecks),
|
||||
confidenceAmount: message.confidenceAmount,
|
||||
confidenceAccount: message.confidenceAccount,
|
||||
confidenceType: message.confidenceType,
|
||||
confidenceMerchant: message.confidenceMerchant,
|
||||
confidenceCategory: message.confidenceCategory,
|
||||
);
|
||||
state = const AsyncData(null);
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
/// Привязка сообщения к транзакции, сохранённой через полную форму
|
||||
/// (карандаш на карточке): status → applied, сообщение уходит из Inbox.
|
||||
Future<void> markApplied(RawMessage message, String transactionId) =>
|
||||
ref
|
||||
.read(rawMessagesRepositoryProvider)
|
||||
.linkTransaction(message.id, transactionId);
|
||||
|
||||
/// «Попробовать снова»: сбросить сообщение в очередь на повторный разбор
|
||||
/// (status → pending, попытки → 0). Воркер переобработает его заново.
|
||||
@@ -186,11 +170,157 @@ class InboxController extends _$InboxController {
|
||||
await ref.read(rawMessagesRepositoryProvider).resetForRetry(message.id);
|
||||
}
|
||||
|
||||
/// «Игнорировать»: статус ignored, без правила.
|
||||
/// «Игнорировать»: статус ignored, без правила. У склеенной карточки
|
||||
/// перевода гасит обе половинки.
|
||||
Future<void> ignore(RawMessage message) async {
|
||||
final repo = ref.read(rawMessagesRepositoryProvider);
|
||||
await repo.updateStatus(message.id, RawMessageStatus.ignored);
|
||||
final pairedId = decodeDraftBundle(message.draftJson)?.pairedRawMessageId;
|
||||
if (pairedId != null) {
|
||||
await repo.updateStatus(pairedId, RawMessageStatus.ignored);
|
||||
}
|
||||
}
|
||||
|
||||
/// «Подтвердить» склеенную карточку перевода: одна транзакция
|
||||
/// type=transfer, обе половинки → applied с общим transactionId.
|
||||
Future<void> confirmPair({
|
||||
required String userId,
|
||||
required RawMessage message,
|
||||
required ParseDraft draft,
|
||||
required String secondaryId,
|
||||
required String fromAccountId,
|
||||
required String toAccountId,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final tx = await ref
|
||||
.read(transactionsControllerProvider.notifier)
|
||||
.createTransaction(
|
||||
userId: userId,
|
||||
accountId: fromAccountId,
|
||||
categoryId: null,
|
||||
type: TransactionType.transfer,
|
||||
amount: draft.amount,
|
||||
date: draft.dateTime ?? message.receivedAt,
|
||||
merchant: draft.merchantCanonical ?? draft.merchantRaw,
|
||||
transferToAccountId: toAccountId,
|
||||
rawMessageId: message.id,
|
||||
);
|
||||
final repo = ref.read(rawMessagesRepositoryProvider);
|
||||
await repo.linkTransaction(message.id, tx.id);
|
||||
await repo.linkTransaction(secondaryId, tx.id);
|
||||
state = const AsyncData(null);
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// «Расклеить» склеенную карточку из Inbox: сигнатура пары в blocklist
|
||||
/// (sweep её больше не склеит), primary восстанавливается из `preMerge`,
|
||||
/// secondary возвращается в Inbox со своим одиночным draftJson.
|
||||
Future<void> unpair({
|
||||
required String userId,
|
||||
required RawMessage message,
|
||||
}) async {
|
||||
final bundle = decodeDraftBundle(message.draftJson);
|
||||
final secondaryId = bundle?.pairedRawMessageId;
|
||||
if (bundle == null || secondaryId == null) return;
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
await ref
|
||||
.read(rawMessagesRepositoryProvider)
|
||||
.updateStatus(message.id, RawMessageStatus.ignored);
|
||||
.read(transferPairingBlocklistRepositoryProvider)
|
||||
.add(userId, pairSignature(message.id, secondaryId));
|
||||
|
||||
final pre = bundle.preMerge;
|
||||
final preType = pre?['type'];
|
||||
final restored = bundle.draft.copyWith(
|
||||
type: preType is String
|
||||
? TransactionType.values.byName(preType)
|
||||
: bundle.draft.type,
|
||||
categoryId: pre?['categoryId'] as String?,
|
||||
transferToAccountId: null,
|
||||
);
|
||||
final repo = ref.read(rawMessagesRepositoryProvider);
|
||||
await repo.updateAfterParse(
|
||||
id: message.id,
|
||||
status: RawMessageStatus.inbox,
|
||||
draftJson: encodeDraftBundle(restored, bundle.suggestion,
|
||||
accountTrusted: bundle.accountTrusted),
|
||||
confidenceAmount: message.confidenceAmount,
|
||||
confidenceAccount: message.confidenceAccount,
|
||||
confidenceType: message.confidenceType,
|
||||
confidenceMerchant: message.confidenceMerchant,
|
||||
confidenceCategory: message.confidenceCategory,
|
||||
);
|
||||
await repo.clearPairing(message.id);
|
||||
await repo.clearPairing(secondaryId);
|
||||
await repo.updateStatus(secondaryId, RawMessageStatus.inbox);
|
||||
state = const AsyncData(null);
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// «Расклеить перевод» после доклейки (из журнала): откатывает транзакцию
|
||||
/// по `mergeUndo`, отвязывает половинку и возвращает её в Inbox одиночкой.
|
||||
Future<void> unmergeApplied({
|
||||
required String userId,
|
||||
required RawMessage message,
|
||||
}) async {
|
||||
final bundle = decodeDraftBundle(message.draftJson);
|
||||
final undo = bundle?.mergeUndo;
|
||||
if (bundle == null || undo == null) return;
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final txId = undo['txId'] as String?;
|
||||
if (txId != null) {
|
||||
final tx =
|
||||
await ref.read(transactionRepositoryProvider).findById(txId);
|
||||
if (tx != null) {
|
||||
final prevType = undo['prevType'];
|
||||
await ref
|
||||
.read(transactionsControllerProvider.notifier)
|
||||
.updateTransaction(tx.copyWith(
|
||||
type: prevType is String
|
||||
? TransactionType.values.byName(prevType)
|
||||
: tx.type,
|
||||
accountId: (undo['prevAccountId'] as String?) ?? tx.accountId,
|
||||
categoryId: undo['prevCategoryId'] as String?,
|
||||
transferToAccountId:
|
||||
undo['prevTransferToAccountId'] as String?,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
final partnerId = bundle.pairedRawMessageId;
|
||||
if (partnerId != null) {
|
||||
await ref
|
||||
.read(transferPairingBlocklistRepositoryProvider)
|
||||
.add(userId, pairSignature(message.id, partnerId));
|
||||
}
|
||||
|
||||
final repo = ref.read(rawMessagesRepositoryProvider);
|
||||
await repo.unlinkTransaction(message.id);
|
||||
await repo.updateAfterParse(
|
||||
id: message.id,
|
||||
status: RawMessageStatus.inbox,
|
||||
draftJson: encodeDraftBundle(bundle.draft, bundle.suggestion,
|
||||
accountTrusted: bundle.accountTrusted),
|
||||
confidenceAmount: message.confidenceAmount,
|
||||
confidenceAccount: message.confidenceAccount,
|
||||
confidenceType: message.confidenceType,
|
||||
confidenceMerchant: message.confidenceMerchant,
|
||||
confidenceCategory: message.confidenceCategory,
|
||||
);
|
||||
await repo.clearPairing(message.id);
|
||||
if (partnerId != null) await repo.clearPairing(partnerId);
|
||||
state = const AsyncData(null);
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// «Игнорировать» + правило-исключение (kind=ignore).
|
||||
@@ -204,6 +334,7 @@ class InboxController extends _$InboxController {
|
||||
try {
|
||||
await ref.read(parseRulesRepositoryProvider).create(
|
||||
userId: userId,
|
||||
packageName: message.packageName,
|
||||
kind: ParseRuleKind.ignore,
|
||||
matchMode: matchMode,
|
||||
pattern: pattern,
|
||||
@@ -218,7 +349,9 @@ class InboxController extends _$InboxController {
|
||||
}
|
||||
}
|
||||
|
||||
/// «Учесть все транзакции»: массовое «подтвердить разово».
|
||||
/// «Учесть все транзакции»: массовое «подтвердить разово». Склеенные
|
||||
/// карточки переводов подтверждаются через [confirmPair] и пропускаются,
|
||||
/// пока не выбраны оба счёта.
|
||||
Future<void> applyAll(String userId) async {
|
||||
final messages =
|
||||
await ref.read(rawMessagesRepositoryProvider).watchInbox(userId).first;
|
||||
@@ -227,6 +360,23 @@ class InboxController extends _$InboxController {
|
||||
for (final msg in messages) {
|
||||
final bundle = decodeDraftBundle(msg.draftJson);
|
||||
if (bundle == null) continue;
|
||||
|
||||
final secondaryId = bundle.pairedRawMessageId;
|
||||
if (secondaryId != null) {
|
||||
final from = bundle.draft.accountId;
|
||||
final to = bundle.draft.transferToAccountId;
|
||||
if (from == null || to == null) continue;
|
||||
await confirmPair(
|
||||
userId: userId,
|
||||
message: msg,
|
||||
draft: bundle.draft,
|
||||
secondaryId: secondaryId,
|
||||
fromAccountId: from,
|
||||
toAccountId: to,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
final accountId = bundle.draft.accountId ?? defaultAccount?.id;
|
||||
if (accountId == null) continue;
|
||||
await confirmOnce(
|
||||
@@ -239,16 +389,6 @@ class InboxController extends _$InboxController {
|
||||
}
|
||||
}
|
||||
|
||||
/// «Скрыть разово»: убрать из Inbox без создания транзакций.
|
||||
Future<void> hideAll(String userId) async {
|
||||
final messages =
|
||||
await ref.read(rawMessagesRepositoryProvider).watchInbox(userId).first;
|
||||
final repo = ref.read(rawMessagesRepositoryProvider);
|
||||
for (final msg in messages) {
|
||||
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
|
||||
}
|
||||
}
|
||||
|
||||
/// Создаёт привязку «карта → счёт», если её ещё нет — чтобы следующее
|
||||
/// сообщение того же мерчанта прошло gate молча (account score 100).
|
||||
Future<void> _maybeBind(
|
||||
|
||||
@@ -7,11 +7,13 @@ import '../data/repositories/parse_rules_repository_impl.dart';
|
||||
import '../data/repositories/raw_messages_repository_impl.dart';
|
||||
import '../data/repositories/rule_candidates_repository_impl.dart';
|
||||
import '../data/repositories/source_apps_repository_impl.dart';
|
||||
import '../data/repositories/transfer_pairing_blocklist_repository_impl.dart';
|
||||
import '../domain/repositories/account_bindings_repository.dart';
|
||||
import '../domain/repositories/parse_rules_repository.dart';
|
||||
import '../domain/repositories/raw_messages_repository.dart';
|
||||
import '../domain/repositories/rule_candidates_repository.dart';
|
||||
import '../domain/repositories/source_apps_repository.dart';
|
||||
import '../domain/repositories/transfer_pairing_blocklist_repository.dart';
|
||||
|
||||
part 'notification_parsing_providers.g.dart';
|
||||
|
||||
@@ -39,6 +41,12 @@ AccountBindingsRepository accountBindingsRepository(Ref ref) =>
|
||||
SourceAppsRepository sourceAppsRepository(Ref ref) =>
|
||||
SourceAppsRepositoryImpl(ref.watch(appDatabaseProvider).sourceAppsDao);
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
TransferPairingBlocklistRepository transferPairingBlocklistRepository(
|
||||
Ref ref) =>
|
||||
TransferPairingBlocklistRepositoryImpl(
|
||||
ref.watch(appDatabaseProvider).transferPairingBlocklistDao);
|
||||
|
||||
/// Множество включённых packageName пользователя. Drift-стрим: эмитит каждое
|
||||
/// изменение allowlist, чтобы native-синк в [NotificationIngestWorker] не
|
||||
/// протухал до перезапуска. Подписка на стрим требует прямого слушателя
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../../core/database/converters/enum_converters.dart';
|
||||
import '../../accounts/application/account_providers.dart';
|
||||
import '../../categories/application/category_providers.dart';
|
||||
import '../../categories/domain/entities/category.dart';
|
||||
import '../../transactions/application/transaction_providers.dart';
|
||||
import '../../transactions/application/transactions_controller.dart';
|
||||
import '../data/deepseek/deepseek_client.dart';
|
||||
import '../data/parser/account_resolver.dart';
|
||||
@@ -12,6 +14,7 @@ import '../data/parser/decision_gate.dart';
|
||||
import '../data/parser/draft_codec.dart';
|
||||
import '../data/parser/rule_lookup.dart';
|
||||
import '../data/parser/rule_suggester.dart';
|
||||
import '../data/parser/transfer_pair_matcher.dart';
|
||||
import '../domain/entities/parse_draft.dart';
|
||||
import '../domain/entities/raw_message.dart';
|
||||
import '../domain/entities/rule_candidate.dart';
|
||||
@@ -48,14 +51,12 @@ class ParsingPipeline {
|
||||
if (!settings.enabled) return; // фича выключена — оставляем pending.
|
||||
|
||||
// Allowlist (§A): парсим только включённые приложения-источники. Делаем
|
||||
// ДО AI, чтобы не тратить токены на посторонние пакеты. Снапшот берём из
|
||||
// репозитория напрямую (`.first` Drift-стрима): свежий на каждый вызов;
|
||||
// stream-провайдер без прямого слушателя не подписался бы на стрим.
|
||||
final enabled = await _ref
|
||||
// ДО AI, чтобы не тратить токены на посторонние пакеты. Точечный lookup
|
||||
// вместо снапшота стрима: заодно нужен флаг selfMerchant источника.
|
||||
final sourceApp = await _ref
|
||||
.read(sourceAppsRepositoryProvider)
|
||||
.watchEnabledPackages(userId)
|
||||
.first;
|
||||
if (!enabled.contains(msg.packageName)) {
|
||||
.findByPackageName(userId, msg.packageName);
|
||||
if (sourceApp == null || !sourceApp.enabled) {
|
||||
await _ref
|
||||
.read(rawMessagesRepositoryProvider)
|
||||
.updateStatus(msg.id, RawMessageStatus.ignored);
|
||||
@@ -66,8 +67,9 @@ class ParsingPipeline {
|
||||
// на пуши, которые пользователь явно просил пропускать («Доставлен заказ»).
|
||||
// `exact`-правила матчат имя мерчанта, доступное только после AI, — их
|
||||
// проверяет findIgnoreRule в _runPipeline.
|
||||
final bodyRules =
|
||||
await _ref.read(parseRulesRepositoryProvider).getEnabledByUser(userId);
|
||||
final bodyRules = await _ref
|
||||
.read(parseRulesRepositoryProvider)
|
||||
.getEnabledForApp(userId, msg.packageName);
|
||||
if (findBodyIgnoreRule(bodyRules, msg.body) != null) {
|
||||
await _ref
|
||||
.read(rawMessagesRepositoryProvider)
|
||||
@@ -76,7 +78,8 @@ class ParsingPipeline {
|
||||
}
|
||||
|
||||
// Извлечение через AI (regex-этап удалён). Без AI/сети — Inbox/ignored.
|
||||
await _extractViaAi(userId, msg, settings);
|
||||
await _extractViaAi(userId, msg, settings,
|
||||
selfMerchant: sourceApp.selfMerchant);
|
||||
}
|
||||
|
||||
/// Извлечение через AI: спам без цифр → ignored; зовём AI (если разрешён),
|
||||
@@ -84,8 +87,9 @@ class ParsingPipeline {
|
||||
Future<void> _extractViaAi(
|
||||
String userId,
|
||||
RawMessage msg,
|
||||
ParsingSettings settings,
|
||||
) async {
|
||||
ParsingSettings settings, {
|
||||
required bool selfMerchant,
|
||||
}) async {
|
||||
final repo = _ref.read(rawMessagesRepositoryProvider);
|
||||
final settingsCtrl = _ref.read(parsingSettingsControllerProvider.notifier);
|
||||
|
||||
@@ -161,7 +165,7 @@ class ParsingPipeline {
|
||||
);
|
||||
case AiParseStatus.draft:
|
||||
await _runPipeline(userId, msg, outcome.draft!, settings,
|
||||
categories: categories);
|
||||
categories: categories, selfMerchant: selfMerchant);
|
||||
}
|
||||
} on DeepSeekNetworkException {
|
||||
await repo.updateAfterParse(
|
||||
@@ -189,18 +193,30 @@ class ParsingPipeline {
|
||||
}
|
||||
|
||||
/// Общий хвост pipeline (§5, шаги 3–8) для AI-draft.
|
||||
///
|
||||
/// [selfMerchant] — источник помечен «мерчант — само приложение» (Ozon):
|
||||
/// AI-подсказка категории глушится, merchant→category правило не
|
||||
/// предлагается — категорию пользователь выбирает вручную на карточке.
|
||||
Future<void> _runPipeline(
|
||||
String userId,
|
||||
RawMessage msg,
|
||||
ParseDraft draft0,
|
||||
ParsingSettings settings, {
|
||||
List<Category>? categories,
|
||||
bool selfMerchant = false,
|
||||
}) async {
|
||||
final repo = _ref.read(rawMessagesRepositoryProvider);
|
||||
|
||||
if (selfMerchant) {
|
||||
// «Нет AI-префилла»: карточка читает подсказку из draftJson, поэтому
|
||||
// достаточно снять её здесь — виджету флаг знать не нужно.
|
||||
draft0 = draft0.copyWith(categorySuggestion: null);
|
||||
}
|
||||
|
||||
// 4. Правила грузим раньше — нужны резолверу (senderToAccount).
|
||||
final rules =
|
||||
await _ref.read(parseRulesRepositoryProvider).getEnabledByUser(userId);
|
||||
final rules = await _ref
|
||||
.read(parseRulesRepositoryProvider)
|
||||
.getEnabledForApp(userId, msg.packageName);
|
||||
if (findIgnoreRule(rules, body: msg.body, merchantRaw: draft0.merchantRaw) !=
|
||||
null) {
|
||||
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
|
||||
@@ -249,16 +265,12 @@ class ParsingPipeline {
|
||||
);
|
||||
}
|
||||
|
||||
// Предложение правила для Inbox (только для незнакомого мерчанта, если
|
||||
// пользователь не пометил его как «категории различаются»). Без мерчанта
|
||||
// или для mixed-мерчанта suggestion остаётся null → карточка покажет
|
||||
// «подтвердить разово» вместо кнопки «Создать правило».
|
||||
// Предложение правила для Inbox — только для незнакомого мерчанта и не
|
||||
// для selfMerchant-источников (мерчант там — само приложение, правило
|
||||
// бессмысленно). Без suggestion карточка покажет «Подтвердить» вместо
|
||||
// кнопки «Создать правило».
|
||||
RuleSuggestion? suggestion;
|
||||
if (rule == null &&
|
||||
draft.merchantRaw != null &&
|
||||
findMixedMerchantRule(rules,
|
||||
body: msg.body, merchantRaw: draft.merchantRaw) ==
|
||||
null) {
|
||||
if (!selfMerchant && rule == null && draft.merchantRaw != null) {
|
||||
// AI-подсказка категории: матчим имя на существующую категорию (§7).
|
||||
String? aiCategoryId;
|
||||
String? aiCategoryName;
|
||||
@@ -300,6 +312,25 @@ class ParsingPipeline {
|
||||
categoryCandidate: candidate,
|
||||
);
|
||||
|
||||
// 7. Transfer pairing: transfer-половинка не идёт через gate — паркуется
|
||||
// в waitingPair; склейку/доклейку/релиз по таймауту делает [sweepPairs]
|
||||
// (сериализован воркером). accountTrusted снапшотим в bundle: на момент
|
||||
// sweep resolution уже недоступен, а доклейка требует trusted-счёт.
|
||||
if (settings.transferPairingEnabled && isTransferHalf(draft)) {
|
||||
await repo.holdForPairing(
|
||||
id: msg.id,
|
||||
draftJson: encodeDraftBundle(draft, suggestion,
|
||||
accountTrusted: resolution.trusted),
|
||||
deadline: DateTime.now().add(kPairingWindow),
|
||||
confidenceAmount: scores.amount,
|
||||
confidenceAccount: scores.account,
|
||||
confidenceType: scores.type,
|
||||
confidenceMerchant: scores.merchant,
|
||||
confidenceCategory: scores.category,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 8. Gate: чек-лист именованных проверок (decision_gate.dart).
|
||||
final gate = decide(
|
||||
autoApplyEnabled: settings.autoApplyEnabled,
|
||||
@@ -379,4 +410,237 @@ class ParsingPipeline {
|
||||
.incrementMatchCount(bindingId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Transfer pairing ───────────────────────────────────────────────────────
|
||||
|
||||
/// Один проход склейки переводов. НЕ вызывать напрямую параллельно:
|
||||
/// сериализацию обеспечивает ParsingWorker — все решения о склейке
|
||||
/// принимает одна последовательная точка, гонок нет по построению.
|
||||
///
|
||||
/// Шаги: (1) пары среди waitingPair; (2) для оставшихся — контрпартнёр в
|
||||
/// Inbox (склейка) или среди applied (доклейка в существующую транзакцию);
|
||||
/// (3) релиз просроченных в Inbox одиночными карточками.
|
||||
Future<void> sweepPairs(String userId) async {
|
||||
final repo = _ref.read(rawMessagesRepositoryProvider);
|
||||
final waiting = await repo.watchWaitingPair(userId).first;
|
||||
if (waiting.isEmpty) return;
|
||||
|
||||
// Декодим bundle каждой ждущей половинки; повреждённые (без draft/kind)
|
||||
// остаются в списке waiting и уходят в Inbox по дедлайну на шаге 3.
|
||||
final halves = <_PairHalf>[];
|
||||
for (final m in waiting) {
|
||||
final b = decodeDraftBundle(m.draftJson);
|
||||
if (b != null && isTransferHalf(b.draft)) halves.add((msg: m, bundle: b));
|
||||
}
|
||||
|
||||
final handled = <String>{};
|
||||
|
||||
// 1. Пары среди самих waitingPair (сюда же попадает бэклог после
|
||||
// рестарта и обе половинки, распарсенные параллельно).
|
||||
for (var i = 0; i < halves.length; i++) {
|
||||
final a = halves[i];
|
||||
if (handled.contains(a.msg.id)) continue;
|
||||
final candidates = <(_PairHalf, DateTime)>[];
|
||||
for (var j = i + 1; j < halves.length; j++) {
|
||||
final b = halves[j];
|
||||
if (handled.contains(b.msg.id)) continue;
|
||||
if (!_isCandidatePair(a, b)) continue;
|
||||
if (await _isBlocked(userId, a.msg.id, b.msg.id)) continue;
|
||||
candidates.add((b, b.msg.receivedAt));
|
||||
}
|
||||
if (candidates.isEmpty) continue;
|
||||
final partner = pickNearest(candidates, a.msg.receivedAt);
|
||||
await _mergePair(a, partner);
|
||||
handled..add(a.msg.id)..add(partner.msg.id);
|
||||
}
|
||||
|
||||
// 2. Контрпартнёр среди Inbox (склейка) / applied (доклейка).
|
||||
for (final h in halves) {
|
||||
if (handled.contains(h.msg.id)) continue;
|
||||
final recent = await repo.findRecentByStatuses(
|
||||
userId,
|
||||
h.msg.receivedAt.subtract(kPairingWindow),
|
||||
h.msg.receivedAt.add(kPairingWindow),
|
||||
const [RawMessageStatus.inbox, RawMessageStatus.applied],
|
||||
);
|
||||
|
||||
final inboxCands = <(_PairHalf, DateTime)>[];
|
||||
final appliedCands = <(_PairHalf, DateTime)>[];
|
||||
for (final m in recent) {
|
||||
if (m.id == h.msg.id || m.pairedWithId != null) continue;
|
||||
final b = decodeDraftBundle(m.draftJson);
|
||||
if (b == null || !isTransferHalf(b.draft)) continue;
|
||||
// Уже склеенная merged-карточка второй раз не участвует.
|
||||
if (b.pairedRawMessageId != null) continue;
|
||||
final cand = (msg: m, bundle: b);
|
||||
if (!_isCandidatePair(h, cand)) continue;
|
||||
if (await _isBlocked(userId, h.msg.id, m.id)) continue;
|
||||
if (m.status == RawMessageStatus.inbox) {
|
||||
inboxCands.add((cand, m.receivedAt));
|
||||
} else if (m.transactionId != null) {
|
||||
appliedCands.add((cand, m.receivedAt));
|
||||
}
|
||||
}
|
||||
|
||||
if (inboxCands.isNotEmpty) {
|
||||
// Контрпартнёр уже в Inbox (релизнут по таймауту раньше / парсился
|
||||
// при выключенном пейринге) — обычная склейка, его одиночная
|
||||
// карточка заменяется merged-карточкой.
|
||||
await _mergePair(h, pickNearest(inboxCands, h.msg.receivedAt));
|
||||
handled.add(h.msg.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (appliedCands.isNotEmpty) {
|
||||
// Доклейка — только при разрешённом и trusted счёте новой половинки;
|
||||
// иначе обычный одиночный путь в Inbox без ожидания дедлайна
|
||||
// (пара уже найдена, но склеивать её автоматически нельзя).
|
||||
if (h.bundle.draft.accountId != null && h.bundle.accountTrusted) {
|
||||
final ok =
|
||||
await _lateMerge(h, pickNearest(appliedCands, h.msg.receivedAt));
|
||||
if (ok) {
|
||||
handled.add(h.msg.id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await repo.updateStatus(h.msg.id, RawMessageStatus.inbox);
|
||||
handled.add(h.msg.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Релиз просроченных в Inbox. Только updateStatus: bundle и
|
||||
// confidence уже сохранены при holdForPairing, затирать их нельзя.
|
||||
final now = DateTime.now();
|
||||
for (final m in waiting) {
|
||||
if (handled.contains(m.id)) continue;
|
||||
final deadline = m.pairDeadline;
|
||||
if (deadline == null || !deadline.isAfter(now)) {
|
||||
await repo.updateStatus(m.id, RawMessageStatus.inbox);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Немедленный релиз всех ждущих половинок в Inbox — при выключении
|
||||
/// тумблера склейки (не держим сообщения скрытыми до дедлайна).
|
||||
Future<void> releaseAllWaiting(String userId) async {
|
||||
final repo = _ref.read(rawMessagesRepositoryProvider);
|
||||
final waiting = await repo.watchWaitingPair(userId).first;
|
||||
for (final m in waiting) {
|
||||
await repo.updateStatus(m.id, RawMessageStatus.inbox);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isCandidatePair(_PairHalf a, _PairHalf b) => isPair(
|
||||
a: a.bundle.draft,
|
||||
aReceivedAt: a.msg.receivedAt,
|
||||
b: b.bundle.draft,
|
||||
bReceivedAt: b.msg.receivedAt,
|
||||
);
|
||||
|
||||
Future<bool> _isBlocked(String userId, String idA, String idB) =>
|
||||
_ref
|
||||
.read(transferPairingBlocklistRepositoryProvider)
|
||||
.contains(userId, pairSignature(idA, idB));
|
||||
|
||||
/// Склейка двух непримененных половинок: primary = transferOut (его счёт —
|
||||
/// источник) уходит в Inbox merged-карточкой, secondary скрывается как
|
||||
/// `paired`. Свой draftJson secondary сохраняет — он нужен для расклейки.
|
||||
Future<void> _mergePair(_PairHalf a, _PairHalf b) async {
|
||||
final out = a.bundle.draft.kind == TxKind.transferOut ? a : b;
|
||||
final inn = out == a ? b : a;
|
||||
|
||||
final mergedDraft = out.bundle.draft.copyWith(
|
||||
type: TransactionType.transfer,
|
||||
transferToAccountId: inn.bundle.draft.accountId,
|
||||
categoryId: null,
|
||||
);
|
||||
|
||||
final repo = _ref.read(rawMessagesRepositoryProvider);
|
||||
await repo.updateAfterParse(
|
||||
id: out.msg.id,
|
||||
status: RawMessageStatus.inbox,
|
||||
draftJson: encodeDraftBundle(
|
||||
mergedDraft,
|
||||
// Suggestion сохраняем: merged-карточка его не показывает, но после
|
||||
// расклейки primary снова становится обычной карточкой.
|
||||
out.bundle.suggestion,
|
||||
accountTrusted: out.bundle.accountTrusted,
|
||||
pairedRawMessageId: inn.msg.id,
|
||||
preMerge: {
|
||||
'type': out.bundle.draft.type.name,
|
||||
'categoryId': out.bundle.draft.categoryId,
|
||||
},
|
||||
),
|
||||
confidenceAmount: out.msg.confidenceAmount,
|
||||
confidenceAccount: out.msg.confidenceAccount,
|
||||
confidenceType: out.msg.confidenceType,
|
||||
confidenceMerchant: out.msg.confidenceMerchant,
|
||||
confidenceCategory: out.msg.confidenceCategory,
|
||||
);
|
||||
await repo.setPairing(out.msg.id, inn.msg.id);
|
||||
await repo.setPairing(inn.msg.id, out.msg.id,
|
||||
status: RawMessageStatus.paired);
|
||||
}
|
||||
|
||||
/// Доклейка: контрпартнёр уже применён обычной транзакцией — конвертируем
|
||||
/// её в transfer, новая половинка привязывается к той же транзакции.
|
||||
/// Снапшот прежних полей tx уходит в `mergeUndo` для отката из журнала.
|
||||
Future<bool> _lateMerge(_PairHalf h, _PairHalf applied) async {
|
||||
final txId = applied.msg.transactionId;
|
||||
if (txId == null) return false;
|
||||
final tx = await _ref.read(transactionRepositoryProvider).findById(txId);
|
||||
if (tx == null) return false;
|
||||
|
||||
final newAccountId = h.bundle.draft.accountId!;
|
||||
final updated = applied.bundle.draft.kind == TxKind.transferOut
|
||||
// Существующая tx — списание: её счёт остаётся источником.
|
||||
? tx.copyWith(
|
||||
type: TransactionType.transfer,
|
||||
transferToAccountId: newAccountId,
|
||||
categoryId: null,
|
||||
)
|
||||
// Существующая tx — зачисление: её счёт становится получателем,
|
||||
// источником — счёт новой out-половинки.
|
||||
: tx.copyWith(
|
||||
type: TransactionType.transfer,
|
||||
accountId: newAccountId,
|
||||
transferToAccountId: tx.accountId,
|
||||
categoryId: null,
|
||||
);
|
||||
|
||||
await _ref
|
||||
.read(transactionsControllerProvider.notifier)
|
||||
.updateTransaction(updated);
|
||||
|
||||
final repo = _ref.read(rawMessagesRepositoryProvider);
|
||||
await repo.updateAfterParse(
|
||||
id: h.msg.id,
|
||||
status: RawMessageStatus.applied,
|
||||
draftJson: encodeDraftBundle(
|
||||
h.bundle.draft,
|
||||
h.bundle.suggestion,
|
||||
accountTrusted: h.bundle.accountTrusted,
|
||||
pairedRawMessageId: applied.msg.id,
|
||||
mergeUndo: {
|
||||
'txId': tx.id,
|
||||
'prevType': tx.type.name,
|
||||
'prevAccountId': tx.accountId,
|
||||
'prevCategoryId': tx.categoryId,
|
||||
'prevTransferToAccountId': tx.transferToAccountId,
|
||||
},
|
||||
),
|
||||
confidenceAmount: h.msg.confidenceAmount,
|
||||
confidenceAccount: h.msg.confidenceAccount,
|
||||
confidenceType: h.msg.confidenceType,
|
||||
confidenceMerchant: h.msg.confidenceMerchant,
|
||||
confidenceCategory: h.msg.confidenceCategory,
|
||||
);
|
||||
await repo.linkTransaction(h.msg.id, tx.id);
|
||||
await repo.setPairing(h.msg.id, applied.msg.id);
|
||||
await repo.setPairing(applied.msg.id, h.msg.id);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Половинка пары: сообщение + декодированный bundle.
|
||||
typedef _PairHalf = ({RawMessage msg, DraftBundle bundle});
|
||||
|
||||
@@ -11,6 +11,7 @@ class ParsingSettings {
|
||||
const ParsingSettings({
|
||||
required this.enabled,
|
||||
required this.autoApplyEnabled,
|
||||
required this.transferPairingEnabled,
|
||||
required this.aiConsentGiven,
|
||||
required this.aiModel,
|
||||
required this.aiDailyTokenLimit,
|
||||
@@ -25,6 +26,11 @@ class ParsingSettings {
|
||||
/// gate-проверок (см. `AutoApplyCheck` в decision_gate.dart).
|
||||
final bool autoApplyEnabled;
|
||||
|
||||
/// Склейка переводов между счетами: transfer-половинки ждут пару
|
||||
/// (`waitingPair`) и объединяются в одну transfer-транзакцию. При
|
||||
/// выключении идут обычным одиночным путём.
|
||||
final bool transferPairingEnabled;
|
||||
|
||||
/// Дано ли согласие на отправку текста уведомлений в AI (§7/§12.6).
|
||||
/// Без него AI не вызывается — работает только regex.
|
||||
final bool aiConsentGiven;
|
||||
@@ -50,6 +56,7 @@ class ParsingSettings {
|
||||
ParsingSettings copyWith({
|
||||
bool? enabled,
|
||||
bool? autoApplyEnabled,
|
||||
bool? transferPairingEnabled,
|
||||
bool? aiConsentGiven,
|
||||
String? aiModel,
|
||||
int? aiDailyTokenLimit,
|
||||
@@ -60,6 +67,8 @@ class ParsingSettings {
|
||||
ParsingSettings(
|
||||
enabled: enabled ?? this.enabled,
|
||||
autoApplyEnabled: autoApplyEnabled ?? this.autoApplyEnabled,
|
||||
transferPairingEnabled:
|
||||
transferPairingEnabled ?? this.transferPairingEnabled,
|
||||
aiConsentGiven: aiConsentGiven ?? this.aiConsentGiven,
|
||||
aiModel: aiModel ?? this.aiModel,
|
||||
aiDailyTokenLimit:
|
||||
@@ -74,6 +83,7 @@ const _kEnabled = 'parsing_enabled';
|
||||
// Старый ключ 'parsing_auto_apply_strictness' (75/85/95) больше не читается:
|
||||
// числовой гейт заменён чек-листом, фича до замены не срабатывала ни разу.
|
||||
const _kAutoApply = 'parsing_auto_apply_enabled';
|
||||
const _kTransferPairing = 'parsing_transfer_pairing_enabled';
|
||||
const _kAiConsent = 'ai_consent';
|
||||
const _kAiModel = 'ai_model';
|
||||
const _kAiDailyLimit = 'ai_daily_token_limit';
|
||||
@@ -84,6 +94,7 @@ const kDefaultAiModel = 'deepseek-chat';
|
||||
const _defaultSettings = ParsingSettings(
|
||||
enabled: true,
|
||||
autoApplyEnabled: true,
|
||||
transferPairingEnabled: true,
|
||||
aiConsentGiven: false,
|
||||
aiModel: kDefaultAiModel,
|
||||
aiDailyTokenLimit: null,
|
||||
@@ -107,6 +118,7 @@ class ParsingSettingsController extends _$ParsingSettingsController {
|
||||
final dao = ref.watch(appDatabaseProvider).settingsDao;
|
||||
final enabledStr = await dao.getPreference(_kEnabled);
|
||||
final autoApplyStr = await dao.getPreference(_kAutoApply);
|
||||
final pairingStr = await dao.getPreference(_kTransferPairing);
|
||||
final consentStr = await dao.getPreference(_kAiConsent);
|
||||
final modelStr = await dao.getPreference(_kAiModel);
|
||||
final limitStr = await dao.getPreference(_kAiDailyLimit);
|
||||
@@ -119,6 +131,9 @@ class ParsingSettingsController extends _$ParsingSettingsController {
|
||||
autoApplyEnabled: autoApplyStr == null
|
||||
? _defaultSettings.autoApplyEnabled
|
||||
: autoApplyStr == 'true',
|
||||
transferPairingEnabled: pairingStr == null
|
||||
? _defaultSettings.transferPairingEnabled
|
||||
: pairingStr == 'true',
|
||||
aiConsentGiven: consentStr == 'true',
|
||||
aiModel: (modelStr != null && modelStr.isNotEmpty)
|
||||
? modelStr
|
||||
@@ -144,6 +159,12 @@ class ParsingSettingsController extends _$ParsingSettingsController {
|
||||
state = AsyncData(current.copyWith(autoApplyEnabled: value));
|
||||
}
|
||||
|
||||
Future<void> setTransferPairingEnabled(bool value) async {
|
||||
await _set(_kTransferPairing, '$value');
|
||||
final current = state.value ?? _defaultSettings;
|
||||
state = AsyncData(current.copyWith(transferPairingEnabled: value));
|
||||
}
|
||||
|
||||
Future<void> setDiagnosticMode(bool value) async {
|
||||
await _set(_kDiagnosticMode, '$value');
|
||||
final current = state.value ?? _defaultSettings;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../domain/entities/raw_message.dart';
|
||||
@@ -9,11 +11,6 @@ import 'parsing_settings_controller.dart';
|
||||
|
||||
part 'parsing_worker.g.dart';
|
||||
|
||||
/// Поток необработанных сообщений — вход воркера.
|
||||
@riverpod
|
||||
Stream<List<RawMessage>> pendingMessages(Ref ref, String userId) =>
|
||||
ref.watch(rawMessagesRepositoryProvider).watchPending(userId);
|
||||
|
||||
/// ParsingWorker (§5): foreground-драйвер pipeline. Слушает
|
||||
/// `raw_messages.pending` и прогоняет каждое сообщение через [ParsingPipeline].
|
||||
/// Отвечает только за «когда запускать» (триггер/дедуп/ретраи); сама обработка
|
||||
@@ -21,7 +18,14 @@ Stream<List<RawMessage>> pendingMessages(Ref ref, String userId) =>
|
||||
/// поверх того же кода без дублирования.
|
||||
///
|
||||
/// Идемпотентен: при возврате сообщения в `pending` перепарсивается.
|
||||
/// Провайдер `keepAlive` — активируется чтением из HomeScreen.
|
||||
/// Провайдер `keepAlive` — активируется `ref.watch` из AppScaffold.
|
||||
///
|
||||
/// Оба входа (pending и waitingPair) — прямые подписки на Drift-стримы
|
||||
/// репозитория, БЕЗ промежуточных autoDispose stream-провайдеров: у тех нет
|
||||
/// UI-слушателей, а внутренний `ref.listen` приостановленного воркера (bare
|
||||
/// `ProviderContainer` в тестах) их не активирует — pause-семантика riverpod 3.
|
||||
/// Прямая подписка не зависит от activity-семантики вовсе; в тестах достаточно
|
||||
/// `container.read(parsingWorkerProvider(userId))`.
|
||||
@Riverpod(keepAlive: true)
|
||||
class ParsingWorker extends _$ParsingWorker {
|
||||
final Set<String> _inFlight = {};
|
||||
@@ -30,13 +34,21 @@ class ParsingWorker extends _$ParsingWorker {
|
||||
// (иначе при перезапуске приложения они не ретраятся — §7).
|
||||
bool _wasOnline = false;
|
||||
|
||||
// Сериализация sweep склейки переводов: одновременно работает максимум
|
||||
// один sweepPairs; запрос, пришедший во время работы, выполняется следом.
|
||||
bool _sweeping = false;
|
||||
bool _sweepRequested = false;
|
||||
Timer? _pairTimer;
|
||||
|
||||
@override
|
||||
void build(String userId) {
|
||||
final sub = ref.listen(
|
||||
pendingMessagesProvider(userId),
|
||||
(_, next) {
|
||||
final list = next.value;
|
||||
if (list == null || list.isEmpty) return;
|
||||
// Первая эмиссия Drift-стрима — текущий снапшот: бэклог `pending`,
|
||||
// накопившийся до старта приложения, дренится сразу.
|
||||
final pendingSub = ref
|
||||
.read(rawMessagesRepositoryProvider)
|
||||
.watchPending(userId)
|
||||
.listen((list) {
|
||||
if (list.isEmpty) return;
|
||||
// Фича выключена: pipeline всё равно no-op'нул бы каждое сообщение —
|
||||
// не прогоняем растущий pending заново на каждую новую вставку.
|
||||
// Обратное включение тумблера запускает явный drain (см. enabledSub).
|
||||
@@ -45,10 +57,8 @@ class ParsingWorker extends _$ParsingWorker {
|
||||
return;
|
||||
}
|
||||
_drain(userId, list);
|
||||
},
|
||||
fireImmediately: true,
|
||||
);
|
||||
ref.onDispose(sub.close);
|
||||
});
|
||||
ref.onDispose(pendingSub.cancel);
|
||||
|
||||
// Размораживаем очередь при обратном включении фичи: raw_messages не
|
||||
// менялись, поэтому pending-стрим сам не переэмитит застрявшие сообщения.
|
||||
@@ -75,6 +85,72 @@ class ParsingWorker extends _$ParsingWorker {
|
||||
},
|
||||
);
|
||||
ref.onDispose(onlineSub.close);
|
||||
|
||||
// Transfer pairing: каждая эмиссия waitingPair (новая половинка, склейка,
|
||||
// релиз) запускает sweep и перевзводит таймер ближайшего дедлайна.
|
||||
// Первая эмиссия покрывает старт приложения: дедлайны в БД, рестарт
|
||||
// переживаем. НЕ гейтится флагом enabled — релиз в Inbox не требует AI.
|
||||
final waitingSub = ref
|
||||
.read(rawMessagesRepositoryProvider)
|
||||
.watchWaitingPair(userId)
|
||||
.listen((list) {
|
||||
_armPairTimer(userId, list);
|
||||
if (list.isNotEmpty) _requestSweep(userId);
|
||||
});
|
||||
ref.onDispose(waitingSub.cancel);
|
||||
ref.onDispose(() => _pairTimer?.cancel());
|
||||
|
||||
// Выключение тумблера склейки — немедленный релиз ждущих половинок в
|
||||
// Inbox (не держим сообщения скрытыми до дедлайна).
|
||||
final pairingSub = ref.listen(
|
||||
parsingSettingsControllerProvider,
|
||||
(prev, next) {
|
||||
if (next.value?.transferPairingEnabled == false &&
|
||||
prev?.value?.transferPairingEnabled == true) {
|
||||
ref.read(parsingPipelineProvider).releaseAllWaiting(userId);
|
||||
}
|
||||
},
|
||||
);
|
||||
ref.onDispose(pairingSub.close);
|
||||
}
|
||||
|
||||
/// Перевзводит таймер на ближайший `pairDeadline` (sweep по его истечении
|
||||
/// релизнет просроченные половинки в Inbox).
|
||||
void _armPairTimer(String userId, List<RawMessage> waiting) {
|
||||
_pairTimer?.cancel();
|
||||
_pairTimer = null;
|
||||
DateTime? nearest;
|
||||
for (final m in waiting) {
|
||||
final d = m.pairDeadline;
|
||||
if (d != null && (nearest == null || d.isBefore(nearest))) nearest = d;
|
||||
}
|
||||
if (nearest == null) return;
|
||||
var delay = nearest.difference(DateTime.now());
|
||||
if (delay.isNegative) delay = Duration.zero;
|
||||
// +1s, чтобы при срабатывании дедлайн гарантированно был в прошлом.
|
||||
_pairTimer = Timer(delay + const Duration(seconds: 1), () {
|
||||
_requestSweep(userId);
|
||||
});
|
||||
}
|
||||
|
||||
/// Сериализованный запуск [ParsingPipeline.sweepPairs]: параллельные
|
||||
/// запросы схлопываются в «выполнить ещё раз после текущего прохода».
|
||||
/// Sweep идемпотентен (без работы — без записей), поэтому цикл
|
||||
/// «запись → эмиссия стрима → повторный запрос» затухает сам.
|
||||
Future<void> _requestSweep(String userId) async {
|
||||
if (_sweeping) {
|
||||
_sweepRequested = true;
|
||||
return;
|
||||
}
|
||||
_sweeping = true;
|
||||
try {
|
||||
do {
|
||||
_sweepRequested = false;
|
||||
await ref.read(parsingPipelineProvider).sweepPairs(userId);
|
||||
} while (_sweepRequested);
|
||||
} finally {
|
||||
_sweeping = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain текущего снапшота `pending` — явный триггер при обратном включении
|
||||
|
||||
@@ -19,18 +19,21 @@ Future<ParseRule?> parseRuleById(Ref ref, String id) =>
|
||||
ref.watch(parseRulesRepositoryProvider).findById(id);
|
||||
|
||||
/// Live-превью (§9.3): сообщения за 30 дней, совпадающие с паттерном.
|
||||
/// [packageName] скоупит превью до приложения правила (null — без фильтра).
|
||||
@riverpod
|
||||
Future<List<RawMessage>> ruleMatchPreview(
|
||||
Ref ref,
|
||||
String userId,
|
||||
String pattern,
|
||||
MatchMode mode,
|
||||
String? packageName,
|
||||
) async {
|
||||
if (pattern.trim().isEmpty) return const [];
|
||||
final since = DateTime.now().subtract(const Duration(days: 30));
|
||||
final messages =
|
||||
await ref.watch(rawMessagesRepositoryProvider).recentByUser(userId, since);
|
||||
return messages
|
||||
.where((m) => packageName == null || m.packageName == packageName)
|
||||
.where((m) => patternMatches(pattern, mode, body: m.body))
|
||||
.toList();
|
||||
}
|
||||
@@ -43,6 +46,7 @@ class RulesController extends _$RulesController {
|
||||
|
||||
Future<ParseRule> create({
|
||||
required String userId,
|
||||
required String packageName,
|
||||
required ParseRuleKind kind,
|
||||
required MatchMode matchMode,
|
||||
required String pattern,
|
||||
@@ -55,6 +59,7 @@ class RulesController extends _$RulesController {
|
||||
try {
|
||||
final rule = await ref.read(parseRulesRepositoryProvider).create(
|
||||
userId: userId,
|
||||
packageName: packageName,
|
||||
kind: kind,
|
||||
matchMode: matchMode,
|
||||
pattern: pattern,
|
||||
|
||||
@@ -38,6 +38,9 @@ class SourceAppsController extends _$SourceAppsController {
|
||||
Future<void> setEnabled(String id, {required bool enabled}) =>
|
||||
ref.read(sourceAppsRepositoryProvider).setEnabled(id, enabled: enabled);
|
||||
|
||||
Future<void> setSelfMerchant(String id, {required bool value}) =>
|
||||
ref.read(sourceAppsRepositoryProvider).setSelfMerchant(id, value: value);
|
||||
|
||||
Future<void> delete(String id) =>
|
||||
ref.read(sourceAppsRepositoryProvider).deleteById(id);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,17 @@ String buildSystemPrompt({required List<String> categoryNames}) {
|
||||
- "counterpartyPhone": телефон контрагента (для переводов по СБП), либо null.
|
||||
- "dateTime": ISO-8601 дата-время операции из текста, либо null.
|
||||
- "kind": одно из "purchase" | "refund" | "transfer_out" | "transfer_in" | "fee" | "balance" | "other".
|
||||
|
||||
Правила выбора "kind" и "type". "type" — экономический смысл для бюджета,
|
||||
"kind" — механика операции; жёсткой связки kind=transfer_* → type=transfer НЕТ:
|
||||
- "transfer_out" — исходящий перевод денег («вы перевели», «перевод по СБП»,
|
||||
«перевод на карту/счёт»), НЕ оплата товара/услуги у продавца. type="expense".
|
||||
- "transfer_in" — входящее зачисление переводом от физлица или со своего счёта
|
||||
(«N перевёл(а) вам», «Пополнение с карты *1234», «перевод из банка»).
|
||||
type="income".
|
||||
- Зарплата, соцвыплаты, проценты, кэшбек и прочие зачисления от организаций —
|
||||
это НЕ переводы: kind="other", type="income".
|
||||
- Оплата покупки — kind="purchase", type="expense".
|
||||
- "categorySuggestion": наиболее подходящая категория из списка пользователя
|
||||
(точное название из списка) или null, если ничего не подходит.
|
||||
|
||||
|
||||
@@ -23,11 +23,17 @@ class ParseRulesDao extends DatabaseAccessor<AppDatabase>
|
||||
Future<List<ParseRulesTableData>> getByUser(String userId) =>
|
||||
(select(parseRulesTable)..where((t) => t.userId.equals(userId))).get();
|
||||
|
||||
/// Только активные правила — для pipeline (rule_lookup).
|
||||
Future<List<ParseRulesTableData>> getEnabledByUser(String userId) =>
|
||||
/// Активные правила приложения [packageName] — для pipeline (rule_lookup).
|
||||
/// NULL-строки (легаси без привязки) матчатся в любом приложении.
|
||||
Future<List<ParseRulesTableData>> getEnabledForApp(
|
||||
String userId,
|
||||
String packageName,
|
||||
) =>
|
||||
(select(parseRulesTable)
|
||||
..where((t) =>
|
||||
t.userId.equals(userId) & t.enabled.equals(true))
|
||||
t.userId.equals(userId) &
|
||||
t.enabled.equals(true) &
|
||||
(t.packageName.equals(packageName) | t.packageName.isNull()))
|
||||
..orderBy([
|
||||
(t) => OrderingTerm.desc(t.priority),
|
||||
(t) => OrderingTerm.desc(t.matchCount),
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import '../../../../../core/database/app_database.dart';
|
||||
import '../../../../../core/database/converters/enum_converters.dart';
|
||||
import '../../../../../core/database/tables/transactions_table.dart';
|
||||
import '../tables/raw_messages_table.dart';
|
||||
import '../../../domain/enums.dart';
|
||||
|
||||
part 'raw_messages_dao.g.dart';
|
||||
|
||||
@DriftAccessor(tables: [RawMessagesTable])
|
||||
@DriftAccessor(tables: [RawMessagesTable, TransactionsTable])
|
||||
class RawMessagesDao extends DatabaseAccessor<AppDatabase>
|
||||
with _$RawMessagesDaoMixin {
|
||||
RawMessagesDao(super.db);
|
||||
@@ -43,6 +45,17 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
|
||||
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)]))
|
||||
.watch();
|
||||
|
||||
/// Поток половинок переводов, ждущих пару, — триггер sweep и перевзвод
|
||||
/// таймера дедлайна в ParsingWorker. Порядок — по receivedAt, чтобы sweep
|
||||
/// матчил детерминированно (старшая половинка первой).
|
||||
Stream<List<RawMessagesTableData>> watchWaitingPair(String userId) =>
|
||||
(select(rawMessagesTable)
|
||||
..where((t) =>
|
||||
t.userId.equals(userId) &
|
||||
t.status.equalsValue(RawMessageStatus.waitingPair))
|
||||
..orderBy([(t) => OrderingTerm.asc(t.receivedAt)]))
|
||||
.watch();
|
||||
|
||||
/// Поток всех сообщений пользователя — для экрана «Журнал парсинга».
|
||||
/// Включает любые статусы (`applied`/`ignored`/`failed`/…), новые сверху.
|
||||
Stream<List<RawMessagesTableData>> watchAll(String userId, {int limit = 200}) =>
|
||||
@@ -65,6 +78,39 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
|
||||
.map((rows) => rows.isEmpty ? 0 : (rows.first.data['c'] as int? ?? 0));
|
||||
}
|
||||
|
||||
/// Частые категории подтверждённых транзакций из уведомлений приложения
|
||||
/// [packageName] — для чипов быстрого выбора на карточке Inbox. Порядок:
|
||||
/// по числу транзакций, при равенстве — по свежести.
|
||||
Stream<List<String>> watchTopCategoryIdsForPackage(
|
||||
String userId,
|
||||
String packageName,
|
||||
TransactionType type, {
|
||||
int limit = 4,
|
||||
}) {
|
||||
final query = customSelect(
|
||||
'''
|
||||
SELECT t.category_id AS cat
|
||||
FROM transactions t
|
||||
JOIN raw_messages m ON m.id = t.raw_message_id
|
||||
WHERE t.user_id = ?1 AND m.package_name = ?2 AND t.type = ?3
|
||||
AND t.category_id IS NOT NULL
|
||||
GROUP BY t.category_id
|
||||
ORDER BY COUNT(*) DESC, MAX(t.date) DESC
|
||||
LIMIT ?4
|
||||
''',
|
||||
variables: [
|
||||
Variable<String>(userId),
|
||||
Variable<String>(packageName),
|
||||
Variable<String>(const TransactionTypeConverter().toSql(type)),
|
||||
Variable<int>(limit),
|
||||
],
|
||||
readsFrom: {rawMessagesTable, transactionsTable},
|
||||
);
|
||||
return query
|
||||
.watch()
|
||||
.map((rows) => rows.map((r) => r.data['cat'] as String).toList());
|
||||
}
|
||||
|
||||
// ── Lookups ────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<RawMessagesTableData?> findById(String id) =>
|
||||
@@ -96,6 +142,22 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
|
||||
/// Сообщения в [statuses] с `receivedAt ∈ [from, to]` — кандидаты в
|
||||
/// контрпартнёры пары для sweep (фильтр по kind делается в Dart по draftJson).
|
||||
Future<List<RawMessagesTableData>> findRecentByStatuses(
|
||||
String userId,
|
||||
DateTime from,
|
||||
DateTime to,
|
||||
List<RawMessageStatus> statuses,
|
||||
) =>
|
||||
(select(rawMessagesTable)
|
||||
..where((t) =>
|
||||
t.userId.equals(userId) &
|
||||
t.receivedAt.isBetweenValues(from, to) &
|
||||
t.status.isInValues(statuses))
|
||||
..orderBy([(t) => OrderingTerm.asc(t.receivedAt)]))
|
||||
.get();
|
||||
|
||||
// ── Mutations ──────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> insert(RawMessagesTableCompanion companion) =>
|
||||
@@ -140,6 +202,58 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
|
||||
),
|
||||
);
|
||||
|
||||
/// Паркует transfer-половинку в ожидание пары: draft сохранён, AI повторно
|
||||
/// не зовётся; sweep либо склеит её, либо релизнет в Inbox по [deadline].
|
||||
Future<void> holdForPairing({
|
||||
required String id,
|
||||
required String draftJson,
|
||||
required DateTime deadline,
|
||||
int? confidenceAmount,
|
||||
int? confidenceAccount,
|
||||
int? confidenceType,
|
||||
int? confidenceMerchant,
|
||||
int? confidenceCategory,
|
||||
}) =>
|
||||
(update(rawMessagesTable)..where((t) => t.id.equals(id))).write(
|
||||
RawMessagesTableCompanion(
|
||||
status: const Value(RawMessageStatus.waitingPair),
|
||||
draftJson: Value(draftJson),
|
||||
pairDeadline: Value(deadline),
|
||||
confidenceAmount: Value(confidenceAmount),
|
||||
confidenceAccount: Value(confidenceAccount),
|
||||
confidenceType: Value(confidenceType),
|
||||
confidenceMerchant: Value(confidenceMerchant),
|
||||
confidenceCategory: Value(confidenceCategory),
|
||||
),
|
||||
);
|
||||
|
||||
/// Связывает половинку с парой ([pairedWithId]) и, опционально, меняет
|
||||
/// статус (secondary → paired). Дедлайн ожидания при этом снимается.
|
||||
Future<void> setPairing(String id, String pairedWithId,
|
||||
{RawMessageStatus? status}) =>
|
||||
(update(rawMessagesTable)..where((t) => t.id.equals(id))).write(
|
||||
RawMessagesTableCompanion(
|
||||
pairedWithId: Value(pairedWithId),
|
||||
pairDeadline: const Value(null),
|
||||
status: status == null ? const Value.absent() : Value(status),
|
||||
),
|
||||
);
|
||||
|
||||
/// Расклейка: снимает связь с парой (и дедлайн, если оставался).
|
||||
Future<void> clearPairing(String id) =>
|
||||
(update(rawMessagesTable)..where((t) => t.id.equals(id))).write(
|
||||
const RawMessagesTableCompanion(
|
||||
pairedWithId: Value(null),
|
||||
pairDeadline: Value(null),
|
||||
),
|
||||
);
|
||||
|
||||
/// Откат доклейки: отвязка от транзакции (статус меняет вызывающий).
|
||||
Future<void> unlinkTransaction(String id) =>
|
||||
(update(rawMessagesTable)..where((t) => t.id.equals(id))).write(
|
||||
const RawMessagesTableCompanion(transactionId: Value(null)),
|
||||
);
|
||||
|
||||
/// Привязка к созданной транзакции (auto-apply).
|
||||
Future<void> linkTransaction(String id, String transactionId) =>
|
||||
(update(rawMessagesTable)..where((t) => t.id.equals(id))).write(
|
||||
|
||||
@@ -44,6 +44,10 @@ class SourceAppsDao extends DatabaseAccessor<AppDatabase>
|
||||
(update(sourceAppsTable)..where((t) => t.id.equals(id)))
|
||||
.write(SourceAppsTableCompanion(enabled: Value(enabled)));
|
||||
|
||||
Future<void> setSelfMerchant(String id, {required bool value}) =>
|
||||
(update(sourceAppsTable)..where((t) => t.id.equals(id)))
|
||||
.write(SourceAppsTableCompanion(selfMerchant: Value(value)));
|
||||
|
||||
Future<int> deleteById(String id) =>
|
||||
(delete(sourceAppsTable)..where((t) => t.id.equals(id))).go();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import '../../../../../core/database/app_database.dart';
|
||||
import '../tables/transfer_pairing_blocklist_table.dart';
|
||||
|
||||
part 'transfer_pairing_blocklist_dao.g.dart';
|
||||
|
||||
@DriftAccessor(tables: [TransferPairingBlocklistTable])
|
||||
class TransferPairingBlocklistDao extends DatabaseAccessor<AppDatabase>
|
||||
with _$TransferPairingBlocklistDaoMixin {
|
||||
TransferPairingBlocklistDao(super.db);
|
||||
|
||||
Future<void> insertEntry(TransferPairingBlocklistTableCompanion companion) =>
|
||||
into(transferPairingBlocklistTable).insert(companion);
|
||||
|
||||
Future<bool> contains(String userId, String signature) async {
|
||||
final row = await (select(transferPairingBlocklistTable)
|
||||
..where((t) => t.userId.equals(userId) & t.signature.equals(signature))
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
return row != null;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,12 @@ class ParseRulesTable extends Table {
|
||||
TextColumn get userId =>
|
||||
text().references(UsersTable, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
/// Приложение-источник, к которому привязано правило (per-app scope).
|
||||
/// Nullable в SQL (упрощает миграцию), но все пути создания обязаны
|
||||
/// передавать значение; NULL-строки матчатся в любом приложении (легаси).
|
||||
/// Без FK на source_apps — зеркалим паттерн account_bindings.
|
||||
TextColumn get packageName => text().nullable()();
|
||||
|
||||
/// merchantToCategory | senderToAccount | ignore
|
||||
TextColumn get kind => text().map(const ParseRuleKindConverter())();
|
||||
|
||||
|
||||
@@ -49,6 +49,14 @@ class RawMessagesTable extends Table {
|
||||
.references(TransactionsTable, #id, onDelete: KeyAction.setNull)
|
||||
.nullable()();
|
||||
|
||||
/// id второй половинки склеенного перевода (transfer pairing).
|
||||
/// У primary указывает на secondary и наоборот. Null — вне пары.
|
||||
TextColumn get pairedWithId => text().nullable()();
|
||||
|
||||
/// Дедлайн ожидания пары для статуса `waitingPair`: по истечении sweep
|
||||
/// релизит сообщение в Inbox одиночной карточкой.
|
||||
DateTimeColumn get pairDeadline => dateTime().nullable()();
|
||||
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
@override
|
||||
|
||||
@@ -21,6 +21,12 @@ class SourceAppsTable extends Table {
|
||||
TextColumn get displayName => text().nullable()();
|
||||
|
||||
BoolColumn get enabled => boolean().withDefault(const Constant(true))();
|
||||
|
||||
/// Уведомления не называют продавца: мерчант — само приложение (Ozon,
|
||||
/// маркетплейсы). Pipeline глушит AI-подсказку категории и не предлагает
|
||||
/// merchant→category правило для таких источников.
|
||||
BoolColumn get selfMerchant => boolean().withDefault(const Constant(false))();
|
||||
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
@override
|
||||
|
||||
@@ -6,6 +6,7 @@ extension ParseRuleMapper on ParseRulesTableData {
|
||||
ParseRule toDomain() => ParseRule(
|
||||
id: id,
|
||||
userId: userId,
|
||||
packageName: packageName,
|
||||
kind: kind,
|
||||
matchMode: matchMode,
|
||||
pattern: pattern,
|
||||
|
||||
@@ -22,6 +22,8 @@ extension RawMessageMapper on RawMessagesTableData {
|
||||
confidenceMerchant: confidenceMerchant,
|
||||
confidenceCategory: confidenceCategory,
|
||||
transactionId: transactionId,
|
||||
pairedWithId: pairedWithId,
|
||||
pairDeadline: pairDeadline,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ extension SourceAppMapper on SourceAppsTableData {
|
||||
packageName: packageName,
|
||||
displayName: displayName,
|
||||
enabled: enabled,
|
||||
selfMerchant: selfMerchant,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ class DraftBundle {
|
||||
required this.draft,
|
||||
this.suggestion,
|
||||
this.failedChecks = const {},
|
||||
this.accountTrusted = false,
|
||||
this.pairedRawMessageId,
|
||||
this.preMerge,
|
||||
this.mergeUndo,
|
||||
});
|
||||
|
||||
final ParseDraft draft;
|
||||
@@ -25,18 +29,43 @@ class DraftBundle {
|
||||
/// Gate-проверки, не пройденные при отправке в Inbox — для строки
|
||||
/// «почему не автоматически» в карточке/журнале.
|
||||
final Set<AutoApplyCheck> failedChecks;
|
||||
|
||||
/// Снапшот `AccountResolution.trusted` на момент парсинга — sweep решает
|
||||
/// по нему допустимость доклейки (resolution к тому моменту уже недоступен).
|
||||
final bool accountTrusted;
|
||||
|
||||
/// id второй половинки склеенного перевода. != null → merged-карточка
|
||||
/// «Перевод между счетами» (или доклеенная половинка при [mergeUndo]).
|
||||
final String? pairedRawMessageId;
|
||||
|
||||
/// Снапшот полей primary-draft, перезаписанных склейкой
|
||||
/// (`{'type', 'categoryId'}`) — для восстановления при расклейке из Inbox.
|
||||
final Map<String, dynamic>? preMerge;
|
||||
|
||||
/// Снапшот полей транзакции до доклейки (`{'txId', 'prevType',
|
||||
/// 'prevAccountId', 'prevCategoryId', 'prevTransferToAccountId'}`) —
|
||||
/// для отката доклеенного перевода из журнала.
|
||||
final Map<String, dynamic>? mergeUndo;
|
||||
}
|
||||
|
||||
String encodeDraftBundle(
|
||||
ParseDraft draft,
|
||||
RuleSuggestion? suggestion, {
|
||||
Set<AutoApplyCheck> failedChecks = const {},
|
||||
bool accountTrusted = false,
|
||||
String? pairedRawMessageId,
|
||||
Map<String, dynamic>? preMerge,
|
||||
Map<String, dynamic>? mergeUndo,
|
||||
}) {
|
||||
return jsonEncode(<String, dynamic>{
|
||||
'draft': _draftToJson(draft),
|
||||
if (suggestion != null) 'suggestion': _suggestionToJson(suggestion),
|
||||
if (failedChecks.isNotEmpty)
|
||||
'failedChecks': failedChecks.map((c) => c.name).toList(),
|
||||
if (accountTrusted) 'accountTrusted': true,
|
||||
'pairedRawMessageId': ?pairedRawMessageId,
|
||||
'preMerge': ?preMerge,
|
||||
'mergeUndo': ?mergeUndo,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,6 +79,10 @@ DraftBundle? decodeDraftBundle(String? json) {
|
||||
draft: _draftFromJson(draftMap),
|
||||
suggestion: suggMap == null ? null : _suggestionFromJson(suggMap),
|
||||
failedChecks: _failedChecksFromJson(map['failedChecks']),
|
||||
accountTrusted: map['accountTrusted'] as bool? ?? false,
|
||||
pairedRawMessageId: map['pairedRawMessageId'] as String?,
|
||||
preMerge: (map['preMerge'] as Map<String, dynamic>?),
|
||||
mergeUndo: (map['mergeUndo'] as Map<String, dynamic>?),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -75,26 +75,6 @@ ParseRule? findSenderRule(
|
||||
return matches.isEmpty ? null : matches.first;
|
||||
}
|
||||
|
||||
/// Ищет активный маркер `mixedMerchant` для мерчанта (категория варьируется).
|
||||
///
|
||||
/// Наличие такого правила подавляет предложение merchant→category-правила в
|
||||
/// Inbox (§9.1): pipeline не строит `RuleSuggestion`, и карточка показывает
|
||||
/// «подтвердить разово» вместо кнопки «Создать правило».
|
||||
ParseRule? findMixedMerchantRule(
|
||||
List<ParseRule> rules, {
|
||||
required String body,
|
||||
String? merchantRaw,
|
||||
}) {
|
||||
final matches = rules
|
||||
.where((r) =>
|
||||
r.enabled &&
|
||||
r.kind == ParseRuleKind.mixedMerchant &&
|
||||
ruleMatches(r, body: body, merchantRaw: merchantRaw))
|
||||
.toList()
|
||||
..sort(_bySpecificity);
|
||||
return matches.isEmpty ? null : matches.first;
|
||||
}
|
||||
|
||||
/// Ищет активное правило-исключение (kind=ignore) для сообщения.
|
||||
ParseRule? findIgnoreRule(
|
||||
List<ParseRule> rules, {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import '../../domain/entities/parse_draft.dart';
|
||||
import '../../domain/enums.dart';
|
||||
|
||||
/// Чистые функции матчинга половинок перевода (transfer pairing).
|
||||
///
|
||||
/// Пара = два уведомления о списании/зачислении одного перевода между своими
|
||||
/// счетами. Асинхронные проверки (blocklist) делает вызывающий код
|
||||
/// (`ParsingPipeline.sweepPairs`), здесь — только детерминированные предикаты.
|
||||
|
||||
/// Окно матчинга и ожидания пары: |receivedAt₁ − receivedAt₂| ≤ 5 мин;
|
||||
/// столько же живёт статус `waitingPair` до релиза в Inbox.
|
||||
const Duration kPairingWindow = Duration(minutes: 5);
|
||||
|
||||
/// Кандидат в пейринг: draft с гранулярным kind перевода. kind=null и все
|
||||
/// прочие виды (purchase, fee, …) идут обычным путём.
|
||||
bool isTransferHalf(ParseDraft draft) =>
|
||||
draft.kind == TxKind.transferOut || draft.kind == TxKind.transferIn;
|
||||
|
||||
/// Предикат пары. [aReceivedAt]/[bReceivedAt] — времена получения сообщений
|
||||
/// (в draft времени может не быть). Blocklist-сигнатуру проверяет вызывающий.
|
||||
bool isPair({
|
||||
required ParseDraft a,
|
||||
required DateTime aReceivedAt,
|
||||
required ParseDraft b,
|
||||
required DateTime bReceivedAt,
|
||||
}) {
|
||||
// Противоположные kind'ы (out ↔ in).
|
||||
final opposite =
|
||||
(a.kind == TxKind.transferOut && b.kind == TxKind.transferIn) ||
|
||||
(a.kind == TxKind.transferIn && b.kind == TxKind.transferOut);
|
||||
if (!opposite) return false;
|
||||
|
||||
// v1: суммы равны точно (без комиссий/конвертаций), валюты совпадают.
|
||||
if (a.amount != b.amount) return false;
|
||||
if (a.currency != b.currency) return false;
|
||||
|
||||
if (aReceivedAt.difference(bReceivedAt).abs() > kPairingWindow) return false;
|
||||
|
||||
// Перевод самому себе на тот же счёт не бывает: если оба счёта разрешены
|
||||
// и совпали — это не пара. Одинаковый packageName допустим
|
||||
// (внутрибанковский перевод «между счетами»).
|
||||
if (a.accountId != null && b.accountId != null && a.accountId == b.accountId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Из [candidates] выбирает ближайшего по `receivedAt` к [target].
|
||||
/// Кандидаты — пары (значение, receivedAt); список не должен быть пустым.
|
||||
T pickNearest<T>(
|
||||
List<(T, DateTime)> candidates,
|
||||
DateTime target,
|
||||
) {
|
||||
assert(candidates.isNotEmpty);
|
||||
var best = candidates.first;
|
||||
var bestDelta = best.$2.difference(target).abs();
|
||||
for (final c in candidates.skip(1)) {
|
||||
final delta = c.$2.difference(target).abs();
|
||||
if (delta < bestDelta) {
|
||||
best = c;
|
||||
bestDelta = delta;
|
||||
}
|
||||
}
|
||||
return best.$1;
|
||||
}
|
||||
|
||||
/// Сигнатура пары сообщений для blocklist: порядконезависимая, чтобы
|
||||
/// расклейка блокировала повторный матч с любой стороны.
|
||||
String pairSignature(String idA, String idB) =>
|
||||
([idA, idB]..sort()).join('|');
|
||||
@@ -19,8 +19,13 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository {
|
||||
.map((rows) => rows.map((r) => r.toDomain()).toList());
|
||||
|
||||
@override
|
||||
Future<List<ParseRule>> getEnabledByUser(String userId) async =>
|
||||
(await _dao.getEnabledByUser(userId)).map((r) => r.toDomain()).toList();
|
||||
Future<List<ParseRule>> getEnabledForApp(
|
||||
String userId,
|
||||
String packageName,
|
||||
) async =>
|
||||
(await _dao.getEnabledForApp(userId, packageName))
|
||||
.map((r) => r.toDomain())
|
||||
.toList();
|
||||
|
||||
@override
|
||||
Future<List<ParseRule>> getByUser(String userId) async =>
|
||||
@@ -33,6 +38,7 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository {
|
||||
@override
|
||||
Future<ParseRule> create({
|
||||
required String userId,
|
||||
required String packageName,
|
||||
required ParseRuleKind kind,
|
||||
required MatchMode matchMode,
|
||||
required String pattern,
|
||||
@@ -47,6 +53,7 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository {
|
||||
ParseRulesTableCompanion.insert(
|
||||
id: id,
|
||||
userId: userId,
|
||||
packageName: Value(packageName),
|
||||
kind: kind,
|
||||
pattern: pattern,
|
||||
matchMode: Value(matchMode),
|
||||
@@ -65,6 +72,7 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository {
|
||||
Future<void> update(ParseRule rule) => _dao.updateRow(
|
||||
ParseRulesTableCompanion(
|
||||
id: Value(rule.id),
|
||||
packageName: Value(rule.packageName),
|
||||
kind: Value(rule.kind),
|
||||
matchMode: Value(rule.matchMode),
|
||||
pattern: Value(rule.pattern),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../../core/database/app_database.dart';
|
||||
import '../../../../core/database/converters/enum_converters.dart';
|
||||
import '../../domain/entities/raw_message.dart';
|
||||
import '../../domain/enums.dart';
|
||||
import '../../domain/repositories/raw_messages_repository.dart';
|
||||
@@ -32,9 +33,19 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
|
||||
Stream<List<RawMessage>> watchAll(String userId) =>
|
||||
_dao.watchAll(userId).map((rows) => rows.map((r) => r.toDomain()).toList());
|
||||
|
||||
@override
|
||||
Stream<List<RawMessage>> watchWaitingPair(String userId) => _dao
|
||||
.watchWaitingPair(userId)
|
||||
.map((rows) => rows.map((r) => r.toDomain()).toList());
|
||||
|
||||
@override
|
||||
Stream<int> watchInboxCount(String userId) => _dao.watchInboxCount(userId);
|
||||
|
||||
@override
|
||||
Stream<List<String>> watchTopCategoryIds(
|
||||
String userId, String packageName, TransactionType type) =>
|
||||
_dao.watchTopCategoryIdsForPackage(userId, packageName, type);
|
||||
|
||||
@override
|
||||
Future<RawMessage?> findById(String id) async =>
|
||||
(await _dao.findById(id))?.toDomain();
|
||||
@@ -56,6 +67,17 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
|
||||
.map((r) => r.toDomain())
|
||||
.toList();
|
||||
|
||||
@override
|
||||
Future<List<RawMessage>> findRecentByStatuses(
|
||||
String userId,
|
||||
DateTime from,
|
||||
DateTime to,
|
||||
List<RawMessageStatus> statuses,
|
||||
) async =>
|
||||
(await _dao.findRecentByStatuses(userId, from, to, statuses))
|
||||
.map((r) => r.toDomain())
|
||||
.toList();
|
||||
|
||||
@override
|
||||
Future<RawMessage> insertIncoming({
|
||||
required String userId,
|
||||
@@ -130,6 +152,39 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
|
||||
Future<void> linkTransaction(String id, String transactionId) =>
|
||||
_dao.linkTransaction(id, transactionId);
|
||||
|
||||
@override
|
||||
Future<void> holdForPairing({
|
||||
required String id,
|
||||
required String draftJson,
|
||||
required DateTime deadline,
|
||||
int? confidenceAmount,
|
||||
int? confidenceAccount,
|
||||
int? confidenceType,
|
||||
int? confidenceMerchant,
|
||||
int? confidenceCategory,
|
||||
}) =>
|
||||
_dao.holdForPairing(
|
||||
id: id,
|
||||
draftJson: draftJson,
|
||||
deadline: deadline,
|
||||
confidenceAmount: confidenceAmount,
|
||||
confidenceAccount: confidenceAccount,
|
||||
confidenceType: confidenceType,
|
||||
confidenceMerchant: confidenceMerchant,
|
||||
confidenceCategory: confidenceCategory,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> setPairing(String id, String pairedWithId,
|
||||
{RawMessageStatus? status}) =>
|
||||
_dao.setPairing(id, pairedWithId, status: status);
|
||||
|
||||
@override
|
||||
Future<void> clearPairing(String id) => _dao.clearPairing(id);
|
||||
|
||||
@override
|
||||
Future<void> unlinkTransaction(String id) => _dao.unlinkTransaction(id);
|
||||
|
||||
@override
|
||||
Future<void> incrementParseAttempts(String id) =>
|
||||
_dao.incrementParseAttempts(id);
|
||||
|
||||
@@ -42,10 +42,18 @@ class SourceAppsRepositoryImpl implements SourceAppsRepository {
|
||||
return row!.toDomain();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SourceApp?> findByPackageName(String userId, String packageName) async =>
|
||||
(await _dao.findByPackageName(userId, packageName))?.toDomain();
|
||||
|
||||
@override
|
||||
Future<void> setEnabled(String id, {required bool enabled}) =>
|
||||
_dao.setEnabled(id, enabled: enabled);
|
||||
|
||||
@override
|
||||
Future<void> setSelfMerchant(String id, {required bool value}) =>
|
||||
_dao.setSelfMerchant(id, value: value);
|
||||
|
||||
@override
|
||||
Future<void> deleteById(String id) => _dao.deleteById(id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../../core/database/app_database.dart';
|
||||
import '../../domain/repositories/transfer_pairing_blocklist_repository.dart';
|
||||
import '../drift/daos/transfer_pairing_blocklist_dao.dart';
|
||||
|
||||
class TransferPairingBlocklistRepositoryImpl
|
||||
implements TransferPairingBlocklistRepository {
|
||||
const TransferPairingBlocklistRepositoryImpl(this._dao);
|
||||
final TransferPairingBlocklistDao _dao;
|
||||
|
||||
@override
|
||||
Future<void> add(String userId, String signature) async {
|
||||
// Идемпотентно: повторная расклейка той же пары не плодит дублей.
|
||||
if (await _dao.contains(userId, signature)) return;
|
||||
await _dao.insertEntry(
|
||||
TransferPairingBlocklistTableCompanion.insert(
|
||||
id: const Uuid().v4(),
|
||||
userId: userId,
|
||||
signature: signature,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> contains(String userId, String signature) =>
|
||||
_dao.contains(userId, signature);
|
||||
}
|
||||
@@ -20,6 +20,10 @@ abstract class ParseRule with _$ParseRule {
|
||||
const factory ParseRule({
|
||||
required String id,
|
||||
required String userId,
|
||||
|
||||
/// Приложение-источник, к которому привязано правило. null — легаси
|
||||
/// (глобальное правило, матчится в любом приложении).
|
||||
String? packageName,
|
||||
required ParseRuleKind kind,
|
||||
required MatchMode matchMode,
|
||||
required String pattern,
|
||||
|
||||
@@ -42,6 +42,13 @@ abstract class RawMessage with _$RawMessage {
|
||||
|
||||
/// FK на transactions.id — заполняется когда статус applied.
|
||||
String? transactionId,
|
||||
|
||||
/// id второй половинки склеенного перевода (у primary — secondary и
|
||||
/// наоборот). Null — вне пары.
|
||||
String? pairedWithId,
|
||||
|
||||
/// Дедлайн ожидания пары (только для статуса waitingPair).
|
||||
DateTime? pairDeadline,
|
||||
required DateTime createdAt,
|
||||
}) = _RawMessage;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ abstract class SourceApp with _$SourceApp {
|
||||
required String packageName,
|
||||
String? displayName,
|
||||
@Default(true) bool enabled,
|
||||
|
||||
/// Уведомления не называют продавца: мерчант — само приложение (Ozon).
|
||||
@Default(false) bool selfMerchant,
|
||||
required DateTime createdAt,
|
||||
}) = _SourceApp;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@ enum RawMessageStatus {
|
||||
applied,
|
||||
ignored,
|
||||
failed,
|
||||
|
||||
/// Распарсенная transfer-половинка скрыто ждёт вторую половинку пары
|
||||
/// (окно до `pairDeadline`); по таймауту уходит в Inbox одиночкой.
|
||||
waitingPair,
|
||||
|
||||
/// Вторичная половинка склеенного перевода: скрыта из Inbox, в журнале
|
||||
/// показывается как «объединено в перевод».
|
||||
paired,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -24,11 +32,6 @@ enum ParseRuleKind {
|
||||
merchantToCategory,
|
||||
senderToAccount,
|
||||
ignore,
|
||||
|
||||
/// Маркер «у мерчанта категория варьируется» (Ozon, маркетплейсы): не задаёт
|
||||
/// действия, а подавляет предложение merchant→category-правила в Inbox.
|
||||
/// Создаётся явным действием пользователя «Категории различаются».
|
||||
mixedMerchant,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -7,8 +7,9 @@ abstract interface class ParseRulesRepository {
|
||||
/// Все правила пользователя — для экрана «Правила парсинга».
|
||||
Stream<List<ParseRule>> watchByUser(String userId);
|
||||
|
||||
/// Только активные правила — для pipeline (rule_lookup).
|
||||
Future<List<ParseRule>> getEnabledByUser(String userId);
|
||||
/// Активные правила приложения [packageName] (+ легаси-строки без
|
||||
/// packageName) — для pipeline (rule_lookup).
|
||||
Future<List<ParseRule>> getEnabledForApp(String userId, String packageName);
|
||||
|
||||
Future<List<ParseRule>> getByUser(String userId);
|
||||
|
||||
@@ -17,6 +18,7 @@ abstract interface class ParseRulesRepository {
|
||||
/// Создаёт правило (активно сразу, weight=1). Возвращает сохранённую сущность.
|
||||
Future<ParseRule> create({
|
||||
required String userId,
|
||||
required String packageName,
|
||||
required ParseRuleKind kind,
|
||||
required MatchMode matchMode,
|
||||
required String pattern,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../../../../core/database/converters/enum_converters.dart';
|
||||
import '../entities/raw_message.dart';
|
||||
import '../enums.dart';
|
||||
|
||||
@@ -19,9 +20,18 @@ abstract interface class RawMessagesRepository {
|
||||
/// Все сообщения пользователя (любой статус) — для «Журнала парсинга».
|
||||
Stream<List<RawMessage>> watchAll(String userId);
|
||||
|
||||
/// Половинки переводов, ждущие пару (`waitingPair`) — триггер sweep
|
||||
/// и таймера дедлайна в ParsingWorker.
|
||||
Stream<List<RawMessage>> watchWaitingPair(String userId);
|
||||
|
||||
/// Реактивный счётчик Inbox — для бэджа на Home.
|
||||
Stream<int> watchInboxCount(String userId);
|
||||
|
||||
/// Частые категории подтверждённых транзакций из уведомлений приложения —
|
||||
/// для чипов быстрого выбора на карточке Inbox (по частоте, затем свежести).
|
||||
Stream<List<String>> watchTopCategoryIds(
|
||||
String userId, String packageName, TransactionType type);
|
||||
|
||||
Future<RawMessage?> findById(String id);
|
||||
|
||||
/// Поиск дубля: совпадение [dedupHash] **и** `receivedAt` в пределах
|
||||
@@ -33,6 +43,15 @@ abstract interface class RawMessagesRepository {
|
||||
/// Сообщения за период (для live-превью правил).
|
||||
Future<List<RawMessage>> recentByUser(String userId, DateTime since);
|
||||
|
||||
/// Сообщения в [statuses] с `receivedAt ∈ [from, to]` — кандидаты в
|
||||
/// контрпартнёры пары для sweep.
|
||||
Future<List<RawMessage>> findRecentByStatuses(
|
||||
String userId,
|
||||
DateTime from,
|
||||
DateTime to,
|
||||
List<RawMessageStatus> statuses,
|
||||
);
|
||||
|
||||
/// Идемпотентная вставка нового уведомления. Если сообщение с тем же
|
||||
/// dedup-хэшем уже есть **в пределах дедуп-окна по [receivedAt]** —
|
||||
/// возвращает существующее, не создавая дубль. Тот же текст вне окна
|
||||
@@ -67,6 +86,30 @@ abstract interface class RawMessagesRepository {
|
||||
/// Привязка к созданной транзакции (status → applied).
|
||||
Future<void> linkTransaction(String id, String transactionId);
|
||||
|
||||
/// Паркует transfer-половинку в ожидание пары (status → waitingPair,
|
||||
/// draft сохранён, дедлайн взведён).
|
||||
Future<void> holdForPairing({
|
||||
required String id,
|
||||
required String draftJson,
|
||||
required DateTime deadline,
|
||||
int? confidenceAmount,
|
||||
int? confidenceAccount,
|
||||
int? confidenceType,
|
||||
int? confidenceMerchant,
|
||||
int? confidenceCategory,
|
||||
});
|
||||
|
||||
/// Связывает половинку с парой; [status] — опциональная смена статуса
|
||||
/// (secondary → paired).
|
||||
Future<void> setPairing(String id, String pairedWithId,
|
||||
{RawMessageStatus? status});
|
||||
|
||||
/// Расклейка: снимает связь с парой.
|
||||
Future<void> clearPairing(String id);
|
||||
|
||||
/// Откат доклейки: отвязка от транзакции (статус меняет вызывающий).
|
||||
Future<void> unlinkTransaction(String id);
|
||||
|
||||
Future<void> incrementParseAttempts(String id);
|
||||
|
||||
/// Сброс сообщения на повторную обработку (status → pending, попытки → 0).
|
||||
|
||||
@@ -9,6 +9,10 @@ abstract interface class SourceAppsRepository {
|
||||
/// Стрим, чтобы фильтр и native-синк реагировали на изменения allowlist.
|
||||
Stream<Set<String>> watchEnabledPackages(String userId);
|
||||
|
||||
/// Приложение по packageName — снапшот для pipeline (allowlist + флаг
|
||||
/// [SourceApp.selfMerchant]). null = приложение не добавлено.
|
||||
Future<SourceApp?> findByPackageName(String userId, String packageName);
|
||||
|
||||
/// Добавляет приложение (включено по умолчанию). Возвращает сущность.
|
||||
/// Если строка с таким packageName уже есть — возвращает существующую.
|
||||
Future<SourceApp> add({
|
||||
@@ -19,5 +23,8 @@ abstract interface class SourceAppsRepository {
|
||||
|
||||
Future<void> setEnabled(String id, {required bool enabled});
|
||||
|
||||
/// Флаг «мерчант — само приложение» (Ozon, маркетплейсы).
|
||||
Future<void> setSelfMerchant(String id, {required bool value});
|
||||
|
||||
Future<void> deleteById(String id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/// Blocklist расклеенных пар переводов.
|
||||
///
|
||||
/// v1: сигнатура — разовая, по конкретной паре сообщений
|
||||
/// (`pairSignature(msgIdA, msgIdB)`), не вечный бан пары счетов. Расклейка
|
||||
/// пишет сигнатуру, чтобы sweep не склеил ту же пару снова.
|
||||
abstract interface class TransferPairingBlocklistRepository {
|
||||
Future<void> add(String userId, String signature);
|
||||
|
||||
Future<bool> contains(String userId, String signature);
|
||||
}
|
||||
@@ -79,9 +79,8 @@ class InboxScreen extends ConsumerWidget {
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: p.accent),
|
||||
onPressed: () => ref
|
||||
@@ -90,21 +89,6 @@ class InboxScreen extends ConsumerWidget {
|
||||
child: Text(l10n.inboxApplyAll),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: p.ink,
|
||||
side: BorderSide(color: p.line),
|
||||
),
|
||||
onPressed: () => ref
|
||||
.read(inboxControllerProvider.notifier)
|
||||
.hideAll(userId),
|
||||
child: Text(l10n.inboxHideAll),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -38,7 +38,8 @@ class _ParsingLogScreenState extends ConsumerState<ParsingLogScreen> {
|
||||
_LogFilter.waiting => m.status == RawMessageStatus.pendingAi ||
|
||||
m.status == RawMessageStatus.pending ||
|
||||
m.status == RawMessageStatus.parsing ||
|
||||
m.status == RawMessageStatus.parsed,
|
||||
m.status == RawMessageStatus.parsed ||
|
||||
m.status == RawMessageStatus.waitingPair,
|
||||
_LogFilter.inbox => m.status == RawMessageStatus.inbox ||
|
||||
m.status == RawMessageStatus.parsedPartial,
|
||||
_LogFilter.applied => m.status == RawMessageStatus.applied,
|
||||
@@ -268,6 +269,30 @@ class _LogRowState extends ConsumerState<_LogRow> {
|
||||
),
|
||||
),
|
||||
if (_expanded) _DetailPanel(message: message, bundle: bundle),
|
||||
// Откат доклеенного перевода: кнопка живёт, пока половинка
|
||||
// привязана к транзакции (после отката bundle теряет mergeUndo).
|
||||
if (_expanded &&
|
||||
bundle?.mergeUndo != null &&
|
||||
message.transactionId != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => ref
|
||||
.read(inboxControllerProvider.notifier)
|
||||
.unmergeApplied(userId: message.userId, message: message),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: p.ink,
|
||||
side: BorderSide(color: p.line),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
),
|
||||
icon: const Icon(Icons.link_off, size: 16),
|
||||
label: Text(l10n.parsingUnmergeTransfer,
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (canRetry) ...[
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
@@ -593,5 +618,7 @@ class _StatusBadge extends StatelessWidget {
|
||||
RawMessageStatus.applied => l10n.parsingStatusApplied,
|
||||
RawMessageStatus.ignored => l10n.parsingStatusIgnored,
|
||||
RawMessageStatus.failed => l10n.parsingStatusFailed,
|
||||
RawMessageStatus.waitingPair => l10n.parsingStatusWaitingPair,
|
||||
RawMessageStatus.paired => l10n.parsingStatusPaired,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +75,24 @@ class ParsingSettingsScreen extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_Card(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
secondary: Icon(Icons.swap_horiz, color: p.ink2),
|
||||
title: Text(l10n.parsingTransferPairingLabel,
|
||||
style: TextStyle(fontSize: 14, color: p.ink)),
|
||||
subtitle: Text(l10n.parsingTransferPairingHint,
|
||||
style: TextStyle(fontSize: 12, color: p.ink2)),
|
||||
value: settings?.transferPairingEnabled ?? true,
|
||||
activeThumbColor: p.accent,
|
||||
onChanged: settings == null
|
||||
? null
|
||||
: (v) => controller.setTransferPairingEnabled(v),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (defaultTargetPlatform == TargetPlatform.android) ...[
|
||||
const SizedBox(height: 16),
|
||||
_Card(
|
||||
|
||||
@@ -13,12 +13,15 @@ import '../../../transactions/presentation/widgets/account_picker_sheet.dart';
|
||||
import '../../../transactions/presentation/widgets/category_picker_sheet.dart';
|
||||
import '../../../user/application/active_user_controller.dart';
|
||||
import '../../application/rules_controller.dart';
|
||||
import '../../application/source_apps_controller.dart';
|
||||
import '../../domain/entities/parse_rule.dart';
|
||||
import '../../domain/entities/source_app.dart';
|
||||
import '../../domain/enums.dart';
|
||||
|
||||
/// Предзаполнение редактора при создании правила из Inbox.
|
||||
class RuleEditorPrefill {
|
||||
const RuleEditorPrefill({
|
||||
required this.packageName,
|
||||
required this.pattern,
|
||||
required this.merchantCanonical,
|
||||
this.categoryId,
|
||||
@@ -26,6 +29,7 @@ class RuleEditorPrefill {
|
||||
this.matchMode = MatchMode.contains,
|
||||
});
|
||||
|
||||
final String packageName;
|
||||
final String pattern;
|
||||
final String merchantCanonical;
|
||||
final String? categoryId;
|
||||
@@ -36,6 +40,7 @@ class RuleEditorPrefill {
|
||||
/// Результат редактора в режиме compose (возвращается через pop в Inbox).
|
||||
class RuleEditorResult {
|
||||
const RuleEditorResult({
|
||||
required this.packageName,
|
||||
required this.kind,
|
||||
required this.matchMode,
|
||||
required this.pattern,
|
||||
@@ -45,6 +50,7 @@ class RuleEditorResult {
|
||||
this.priority = 0,
|
||||
});
|
||||
|
||||
final String packageName;
|
||||
final ParseRuleKind kind;
|
||||
final MatchMode matchMode;
|
||||
final String pattern;
|
||||
@@ -72,6 +78,7 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
||||
final _merchantCtrl = TextEditingController();
|
||||
ParseRuleKind _kind = ParseRuleKind.merchantToCategory;
|
||||
MatchMode _matchMode = MatchMode.contains;
|
||||
String? _packageName;
|
||||
String? _categoryId;
|
||||
String? _accountId;
|
||||
int _priority = 0;
|
||||
@@ -81,11 +88,16 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
||||
|
||||
bool get _isEdit => widget.ruleId != null;
|
||||
|
||||
/// Приложение выбирается только при создании «с нуля» из списка правил;
|
||||
/// из Inbox (prefill) и при правке оно зафиксировано (read-only).
|
||||
bool get _appIsPickable => !_isEdit && widget.prefill == null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final pf = widget.prefill;
|
||||
if (pf != null) {
|
||||
_packageName = pf.packageName;
|
||||
_patternCtrl.text = pf.pattern;
|
||||
_merchantCtrl.text = pf.merchantCanonical;
|
||||
_matchMode = pf.matchMode;
|
||||
@@ -104,6 +116,7 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
||||
|
||||
void _hydrateFromRule(ParseRule rule) {
|
||||
if (_initialized) return;
|
||||
_packageName = rule.packageName;
|
||||
_kind = rule.kind;
|
||||
_patternCtrl.text = rule.pattern;
|
||||
_merchantCtrl.text = rule.merchantCanonical ?? '';
|
||||
@@ -116,11 +129,13 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
||||
}
|
||||
|
||||
bool get _isSender => _kind == ParseRuleKind.senderToAccount;
|
||||
bool get _isMixed => _kind == ParseRuleKind.mixedMerchant;
|
||||
|
||||
/// Готовность к сохранению: для sender→account нужен счёт.
|
||||
/// Готовность к сохранению: нужны приложение и (для sender→account) счёт.
|
||||
/// При правке легаси-правила без packageName сохранение не блокируем.
|
||||
bool get _canSave =>
|
||||
_patternCtrl.text.trim().isNotEmpty && (!_isSender || _accountId != null);
|
||||
_patternCtrl.text.trim().isNotEmpty &&
|
||||
(_isEdit || _packageName != null) &&
|
||||
(!_isSender || _accountId != null);
|
||||
|
||||
Future<void> _save(String userId, ParseRule? existing) async {
|
||||
final pattern = _patternCtrl.text.trim();
|
||||
@@ -146,6 +161,7 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
||||
} else {
|
||||
context.pop(
|
||||
RuleEditorResult(
|
||||
packageName: _packageName!,
|
||||
kind: _kind,
|
||||
matchMode: _matchMode,
|
||||
pattern: pattern,
|
||||
@@ -184,6 +200,15 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
||||
final accountName = _accountId == null
|
||||
? null
|
||||
: accounts.where((a) => a.id == _accountId).firstOrNull?.name;
|
||||
final sourceApps = ref.watch(sourceAppsListProvider(userId)).value ??
|
||||
const <SourceApp>[];
|
||||
final appLabel = _packageName == null
|
||||
? l10n.rulesUnknownApp
|
||||
: sourceApps
|
||||
.where((a) => a.packageName == _packageName)
|
||||
.firstOrNull
|
||||
?.displayName ??
|
||||
_packageName!;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: p.paper,
|
||||
@@ -209,13 +234,59 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
||||
// Выбор вида — только при создании из списка правил. Из Inbox
|
||||
// (prefill) композим всегда merchant→category: senderToAccount там
|
||||
// не применяется (inbox_controller создаёт правило этого вида).
|
||||
if (!_isEdit && widget.prefill == null) ...[
|
||||
if (_appIsPickable) ...[
|
||||
_KindSelector(
|
||||
kind: _kind,
|
||||
onChanged: (k) => setState(() => _kind = k),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
// Приложение-источник: правило действует только внутри него.
|
||||
// Выбирается при создании «с нуля»; из Inbox и при правке — read-only.
|
||||
if (_appIsPickable)
|
||||
if (sourceApps.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(l10n.ruleEditorNoApps,
|
||||
style: TextStyle(fontSize: 13, color: p.negative)),
|
||||
)
|
||||
else ...[
|
||||
_FieldRow(
|
||||
label: l10n.ruleEditorApp,
|
||||
child: DropdownButton<String>(
|
||||
value: _packageName,
|
||||
isExpanded: true,
|
||||
hint: Text(l10n.ruleEditorAppPick,
|
||||
textAlign: TextAlign.end,
|
||||
style: TextStyle(fontSize: 14, color: p.ink2)),
|
||||
underline: const SizedBox.shrink(),
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
items: [
|
||||
for (final app in sourceApps)
|
||||
DropdownMenuItem(
|
||||
value: app.packageName,
|
||||
child: Text(app.displayName ?? app.packageName,
|
||||
style: TextStyle(fontSize: 14, color: p.ink)),
|
||||
),
|
||||
],
|
||||
onChanged: (v) => setState(() => _packageName = v),
|
||||
),
|
||||
),
|
||||
_Divider(color: p.line),
|
||||
const SizedBox(height: 12),
|
||||
]
|
||||
else ...[
|
||||
_FieldRow(
|
||||
label: l10n.ruleEditorApp,
|
||||
child: Text(
|
||||
appLabel,
|
||||
textAlign: TextAlign.end,
|
||||
style: TextStyle(fontSize: 14, color: p.ink),
|
||||
),
|
||||
),
|
||||
_Divider(color: p.line),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
Text(l10n.ruleEditorIfContains,
|
||||
style: TextStyle(fontSize: 13, color: p.ink2)),
|
||||
const SizedBox(height: 6),
|
||||
@@ -238,10 +309,6 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
||||
onChanged: (m) => setState(() => _matchMode = m),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
if (_isMixed)
|
||||
Text(l10n.ruleKindMixedHint,
|
||||
style: TextStyle(fontSize: 13, color: p.ink2, height: 1.3)),
|
||||
if (!_isMixed) ...[
|
||||
Text(l10n.ruleEditorThen,
|
||||
style: TextStyle(fontSize: 13, color: p.ink2)),
|
||||
const SizedBox(height: 8),
|
||||
@@ -289,7 +356,6 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
||||
if (id != null) setState(() => _accountId = id);
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
_AdvancedToggle(
|
||||
expanded: _advanced,
|
||||
@@ -326,6 +392,7 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
||||
userId: userId,
|
||||
pattern: _patternCtrl.text,
|
||||
mode: _matchMode,
|
||||
packageName: _packageName,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
FilledButton(
|
||||
@@ -347,19 +414,22 @@ class _MatchesPreview extends ConsumerWidget {
|
||||
required this.userId,
|
||||
required this.pattern,
|
||||
required this.mode,
|
||||
required this.packageName,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final String pattern;
|
||||
final MatchMode mode;
|
||||
final String? packageName;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final p = context.palette;
|
||||
final l10n = context.l10n;
|
||||
if (pattern.trim().isEmpty) return const SizedBox.shrink();
|
||||
final matches =
|
||||
ref.watch(ruleMatchPreviewProvider(userId, pattern, mode)).value ??
|
||||
final matches = ref
|
||||
.watch(ruleMatchPreviewProvider(userId, pattern, mode, packageName))
|
||||
.value ??
|
||||
const [];
|
||||
|
||||
return Column(
|
||||
|
||||
@@ -11,7 +11,9 @@ import '../../../categories/application/categories_controller.dart';
|
||||
import '../../../categories/domain/entities/category.dart';
|
||||
import '../../../user/application/active_user_controller.dart';
|
||||
import '../../application/rules_controller.dart';
|
||||
import '../../application/source_apps_controller.dart';
|
||||
import '../../domain/entities/parse_rule.dart';
|
||||
import '../../domain/entities/source_app.dart';
|
||||
import '../../domain/enums.dart';
|
||||
import '../widgets/rule_card.dart';
|
||||
import 'rule_editor_screen.dart';
|
||||
@@ -56,6 +58,22 @@ class _RulesListScreenState extends ConsumerState<RulesListScreen> {
|
||||
final accounts =
|
||||
ref.watch(accountsStreamProvider(userId)).value ?? const <Account>[];
|
||||
final accountById = {for (final a in accounts) a.id: a};
|
||||
final sourceApps = ref.watch(sourceAppsListProvider(userId)).value ??
|
||||
const <SourceApp>[];
|
||||
final displayNameByPackage = {
|
||||
for (final a in sourceApps) a.packageName: a.displayName,
|
||||
};
|
||||
|
||||
// Секции по приложению-источнику: заголовок = displayName ?? packageName
|
||||
// (легаси-правила без привязки — в секцию «Неизвестное приложение»).
|
||||
final groups = <String, List<ParseRule>>{};
|
||||
for (final r in rules) {
|
||||
final header = r.packageName == null
|
||||
? l10n.rulesUnknownApp
|
||||
: displayNameByPackage[r.packageName] ?? r.packageName!;
|
||||
groups.putIfAbsent(header, () => []).add(r);
|
||||
}
|
||||
final sortedHeaders = groups.keys.toList()..sort();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: p.paper,
|
||||
@@ -105,20 +123,36 @@ class _RulesListScreenState extends ConsumerState<RulesListScreen> {
|
||||
style: TextStyle(fontSize: 14, color: p.ink2)),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: rules.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
Container(height: 1, color: p.line),
|
||||
itemBuilder: (context, i) => RuleCard(
|
||||
rule: rules[i],
|
||||
: ListView(
|
||||
children: [
|
||||
for (final header in sortedHeaders) ...[
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
header,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: p.ink2,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final (i, rule) in groups[header]!.indexed) ...[
|
||||
if (i > 0) Container(height: 1, color: p.line),
|
||||
RuleCard(
|
||||
rule: rule,
|
||||
categoryById: categoryById,
|
||||
accountById: accountById,
|
||||
onTap: () =>
|
||||
context.push(AppRoutes.parsingRuleEdit(rules[i].id)),
|
||||
onTap: () => context
|
||||
.push(AppRoutes.parsingRuleEdit(rule.id)),
|
||||
onToggle: (enabled) => ref
|
||||
.read(rulesControllerProvider.notifier)
|
||||
.setEnabled(rules[i].id, enabled: enabled),
|
||||
.setEnabled(rule.id, enabled: enabled),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -132,6 +166,7 @@ class _RulesListScreenState extends ConsumerState<RulesListScreen> {
|
||||
if (result == null) return;
|
||||
await ref.read(rulesControllerProvider.notifier).create(
|
||||
userId: userId,
|
||||
packageName: result.packageName,
|
||||
kind: result.kind,
|
||||
matchMode: result.matchMode,
|
||||
pattern: result.pattern,
|
||||
|
||||
@@ -338,7 +338,9 @@ class _AddedAppTile extends ConsumerWidget {
|
||||
onTap: () => context.push(AppRoutes.parsingAppBindings(app.packageName)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 8, 8, 8),
|
||||
child: Row(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.apps_outlined, size: 20, color: p.ink2),
|
||||
const SizedBox(width: 12),
|
||||
@@ -367,6 +369,37 @@ class _AddedAppTile extends ConsumerWidget {
|
||||
Icon(Icons.chevron_right, size: 18, color: p.ink2),
|
||||
],
|
||||
),
|
||||
// «Мерчант — само приложение» (Ozon): уведомления не называют
|
||||
// продавца — категория выбирается вручную, правила не предлагаются.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 32),
|
||||
child: Tooltip(
|
||||
message: l10n.sourceAppsSelfMerchantHint,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.storefront_outlined, size: 16, color: p.ink2),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(l10n.sourceAppsSelfMerchantLabel,
|
||||
style: TextStyle(fontSize: 12, color: p.ink2)),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
width: 40,
|
||||
child: Checkbox(
|
||||
value: app.selfMerchant,
|
||||
activeColor: p.accent,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
onChanged: (v) =>
|
||||
ctrl.setSelfMerchant(app.id, value: v ?? false),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ import '../../../../app/l10n/l10n.dart';
|
||||
import '../../../../app/router/app_routes.dart';
|
||||
import '../../../../app/theme/app_colors.dart';
|
||||
import '../../../../core/database/converters/enum_converters.dart';
|
||||
import '../../../accounts/application/accounts_controller.dart';
|
||||
import '../../../categories/domain/entities/category.dart';
|
||||
import '../../../home/presentation/widgets/money_text.dart';
|
||||
import '../../../transactions/presentation/screens/transaction_form_screen.dart';
|
||||
import '../../../transactions/presentation/widgets/account_picker_sheet.dart';
|
||||
import '../../../transactions/presentation/widgets/category_picker_sheet.dart';
|
||||
import '../../application/inbox_controller.dart';
|
||||
import '../../data/parser/draft_codec.dart';
|
||||
@@ -18,7 +21,7 @@ import 'confidence_badge.dart';
|
||||
import 'gate_check_labels.dart';
|
||||
|
||||
/// Карточка Inbox (§12.2): мерчант, сумма, подсветка слабых полей и три
|
||||
/// действия — «Создать правило», «Подтвердить разово», «Игнорировать».
|
||||
/// действия — «Создать правило», «Подтвердить», «Игнорировать».
|
||||
class InboxCard extends ConsumerWidget {
|
||||
const InboxCard({
|
||||
super.key,
|
||||
@@ -50,9 +53,16 @@ class InboxCard extends ConsumerWidget {
|
||||
? _FailedBody(message: message)
|
||||
: bundle == null
|
||||
? _UnrecognizedBody(message: message)
|
||||
// pairedRawMessageId → склеенная пара «Перевод между счетами».
|
||||
: bundle.pairedRawMessageId != null
|
||||
? _TransferPairBody(
|
||||
message: message,
|
||||
userId: userId,
|
||||
bundle: bundle,
|
||||
)
|
||||
// suggestion != null → знакомый мерчант: предлагаем правило.
|
||||
// suggestion == null → нет мерчанта (только сумма) ИЛИ мерчант
|
||||
// помечен как mixed → «подтвердить разово» без создания правила.
|
||||
// suggestion == null → нет мерчанта (только сумма) ИЛИ источник
|
||||
// с selfMerchant → «Подтвердить» без создания правила.
|
||||
: bundle.suggestion != null
|
||||
? _RecognizedBody(
|
||||
message: message,
|
||||
@@ -169,10 +179,11 @@ class _RecognizedBody extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
_CreateRuleButton(
|
||||
_SplitActionButton(
|
||||
label: categoryName != null
|
||||
? l10n.inboxCreateRule(merchant, categoryName)
|
||||
: l10n.inboxCreateRuleNoCategory(merchant),
|
||||
editTooltip: l10n.inboxEditRuleTooltip,
|
||||
onTap: () => _createRule(
|
||||
context,
|
||||
ref,
|
||||
@@ -194,16 +205,24 @@ class _RecognizedBody extends ConsumerWidget {
|
||||
Expanded(
|
||||
child: _SecondaryButton(
|
||||
icon: Icons.check,
|
||||
label: l10n.inboxConfirmOnce,
|
||||
onTap: accountId == null
|
||||
? null
|
||||
: () => ref.read(inboxControllerProvider.notifier).confirmOnce(
|
||||
label: l10n.inboxConfirm,
|
||||
onTap: () async {
|
||||
final acc = await _resolveAccount(context,
|
||||
userId: userId, accountId: accountId);
|
||||
if (acc == null || !context.mounted) return;
|
||||
await _runReporting(
|
||||
context,
|
||||
() => ref
|
||||
.read(inboxControllerProvider.notifier)
|
||||
.confirmOnce(
|
||||
userId: userId,
|
||||
message: message,
|
||||
draft: draft,
|
||||
accountId: accountId,
|
||||
accountId: acc,
|
||||
categoryId: categoryId,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -217,29 +236,6 @@ class _RecognizedBody extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
// «Категории различаются» — мерчант вроде Ozon, у которого категория
|
||||
// меняется от покупки к покупке: правило для него бессмысленно.
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () =>
|
||||
ref.read(inboxControllerProvider.notifier).markMerchantMixed(
|
||||
userId: userId,
|
||||
message: message,
|
||||
bundle: bundle,
|
||||
merchantCanonical: merchant,
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: p.ink2,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
icon: const Icon(Icons.shuffle, size: 14),
|
||||
label: Text(l10n.inboxMarkMixed,
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -251,21 +247,26 @@ class _RecognizedBody extends ConsumerWidget {
|
||||
required String? categoryId,
|
||||
required String merchant,
|
||||
}) async {
|
||||
if (accountId == null) return;
|
||||
final draft = bundle.draft;
|
||||
if (categoryId == null) {
|
||||
await _openEditor(context, ref,
|
||||
accountId: accountId, categoryId: categoryId, merchant: merchant);
|
||||
return;
|
||||
}
|
||||
await ref.read(inboxControllerProvider.notifier).createRule(
|
||||
final acc =
|
||||
await _resolveAccount(context, userId: userId, accountId: accountId);
|
||||
if (acc == null || !context.mounted) return;
|
||||
await _runReporting(
|
||||
context,
|
||||
() => ref.read(inboxControllerProvider.notifier).createRule(
|
||||
userId: userId,
|
||||
message: message,
|
||||
draft: draft,
|
||||
accountId: accountId,
|
||||
accountId: acc,
|
||||
categoryId: categoryId,
|
||||
merchantCanonical: merchant,
|
||||
pattern: draft.merchantRaw ?? merchant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -280,33 +281,65 @@ class _RecognizedBody extends ConsumerWidget {
|
||||
final result = await context.push<RuleEditorResult?>(
|
||||
AppRoutes.parsingRuleNew,
|
||||
extra: RuleEditorPrefill(
|
||||
packageName: message.packageName,
|
||||
pattern: draft.merchantRaw ?? merchant,
|
||||
merchantCanonical: merchant,
|
||||
categoryId: categoryId,
|
||||
accountId: accountId,
|
||||
),
|
||||
);
|
||||
if (result == null) return;
|
||||
final acc = result.accountId ?? accountId;
|
||||
if (acc == null) return;
|
||||
await ref.read(inboxControllerProvider.notifier).createRule(
|
||||
if (result == null || !context.mounted) return;
|
||||
final acc = await _resolveAccount(context,
|
||||
userId: userId, accountId: result.accountId ?? accountId);
|
||||
if (acc == null || !context.mounted) return;
|
||||
await _runReporting(
|
||||
context,
|
||||
() => ref.read(inboxControllerProvider.notifier).createRule(
|
||||
userId: userId,
|
||||
message: message,
|
||||
draft: draft,
|
||||
accountId: acc,
|
||||
categoryId: result.categoryId,
|
||||
merchantCanonical:
|
||||
result.merchantCanonical.isEmpty ? merchant : result.merchantCanonical,
|
||||
merchantCanonical: result.merchantCanonical.isEmpty
|
||||
? merchant
|
||||
: result.merchantCanonical,
|
||||
pattern: result.pattern,
|
||||
matchMode: result.matchMode,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Выполняет действие контроллера и показывает SnackBar при ошибке —
|
||||
/// InboxController делает rethrow, и без обработки тап выглядит как no-op.
|
||||
Future<void> _runReporting(
|
||||
BuildContext context,
|
||||
Future<void> Function() action,
|
||||
) async {
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
}
|
||||
}
|
||||
|
||||
/// Счёт для подтверждения: переданный [accountId] (draft/дефолт), иначе —
|
||||
/// пикер счёта. `null` из пикера = пользователь отменил.
|
||||
Future<String?> _resolveAccount(
|
||||
BuildContext context, {
|
||||
required String userId,
|
||||
String? accountId,
|
||||
}) async {
|
||||
if (accountId != null) return accountId;
|
||||
return showAccountPicker(context, userId: userId);
|
||||
}
|
||||
|
||||
/// Карточка без предложения правила (§9.1): уведомление без мерчанта (только
|
||||
/// сумма) или мерчант помечен как mixed. Создавать merchant→category-правило
|
||||
/// тут нельзя/бессмысленно, поэтому единственное действие — выбрать категорию
|
||||
/// и «подтвердить разово».
|
||||
/// сумма) или источник с selfMerchant (Ozon). Создавать merchant→category
|
||||
/// правило тут нельзя/бессмысленно: пользователь выбирает категорию
|
||||
/// (обязательно) и подтверждает — либо уходит в полную форму карандашом.
|
||||
class _ConfirmOnceBody extends ConsumerStatefulWidget {
|
||||
const _ConfirmOnceBody({
|
||||
required this.message,
|
||||
@@ -358,10 +391,22 @@ class _ConfirmOnceBodyState extends ConsumerState<_ConfirmOnceBody> {
|
||||
draft.merchantRaw ??
|
||||
message.title ??
|
||||
'—';
|
||||
final accountId = draft.accountId ?? widget.defaultAccountId;
|
||||
final categoryName =
|
||||
_categoryId != null ? widget.categoryById[_categoryId]?.name : null;
|
||||
|
||||
// Чипы частых категорий этого приложения — быстрый выбор без пикера.
|
||||
final topCategoryIds = ref
|
||||
.watch(topConfirmCategoriesProvider(
|
||||
widget.userId, message.packageName, draft.type))
|
||||
.value ??
|
||||
const <String>[];
|
||||
final chipCategories = topCategoryIds
|
||||
.map((id) => widget.categoryById[id])
|
||||
.whereType<Category>()
|
||||
.where((c) => !c.archived)
|
||||
.take(4)
|
||||
.toList();
|
||||
|
||||
final signed =
|
||||
draft.type == TransactionType.expense ? -draft.amount : draft.amount;
|
||||
final amountColor =
|
||||
@@ -410,7 +455,22 @@ class _ConfirmOnceBodyState extends ConsumerState<_ConfirmOnceBody> {
|
||||
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3)),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
// Строка выбора категории (необязательно — можно подтвердить и без неё).
|
||||
if (chipCategories.isNotEmpty) ...[
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final c in chipCategories)
|
||||
_CategoryChip(
|
||||
label: c.name,
|
||||
selected: _categoryId == c.id,
|
||||
onTap: () => setState(() => _categoryId = c.id),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
// Строка выбора категории — категория обязательна для подтверждения.
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: () async {
|
||||
@@ -446,31 +506,21 @@ class _ConfirmOnceBodyState extends ConsumerState<_ConfirmOnceBody> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_categoryId == null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(l10n.inboxCategoryRequiredHint,
|
||||
style: TextStyle(fontSize: 12, color: p.ink2)),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: FilledButton.icon(
|
||||
onPressed: accountId == null
|
||||
? null
|
||||
: () => ref
|
||||
.read(inboxControllerProvider.notifier)
|
||||
.confirmOnce(
|
||||
userId: widget.userId,
|
||||
message: message,
|
||||
draft: draft,
|
||||
accountId: accountId,
|
||||
categoryId: _categoryId,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: p.accent,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
icon: const Icon(Icons.check, size: 16),
|
||||
label: Text(l10n.inboxConfirm,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
child: _SplitActionButton(
|
||||
label: l10n.inboxConfirm,
|
||||
editTooltip: l10n.inboxEditRuleTooltip,
|
||||
onTap: _categoryId == null ? null : _confirm,
|
||||
onEdit: _openFullForm,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -487,6 +537,323 @@ class _ConfirmOnceBodyState extends ConsumerState<_ConfirmOnceBody> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Галочка: мгновенная транзакция с выбранной категорией. Без счёта —
|
||||
/// открывает пикер счёта (отмена пикера = ничего не делаем).
|
||||
Future<void> _confirm() async {
|
||||
final message = widget.message;
|
||||
final draft = widget.bundle.draft;
|
||||
final acc = await _resolveAccount(
|
||||
context,
|
||||
userId: widget.userId,
|
||||
accountId: draft.accountId ?? widget.defaultAccountId,
|
||||
);
|
||||
if (acc == null || !mounted) return;
|
||||
await _runReporting(
|
||||
context,
|
||||
() => ref.read(inboxControllerProvider.notifier).confirmOnce(
|
||||
userId: widget.userId,
|
||||
message: message,
|
||||
draft: draft,
|
||||
accountId: acc,
|
||||
categoryId: _categoryId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Карандаш: полная форма транзакции с префиллом — там можно вписать
|
||||
/// реального мерчанта. После сохранения сообщение линкуется (→ applied).
|
||||
Future<void> _openFullForm() async {
|
||||
final message = widget.message;
|
||||
final draft = widget.bundle.draft;
|
||||
final txId = await context.push<String?>(
|
||||
AppRoutes.transactionNew,
|
||||
extra: TransactionFormPrefill(
|
||||
type: draft.type,
|
||||
amountMinor: draft.amount,
|
||||
date: draft.dateTime ?? message.receivedAt,
|
||||
accountId: draft.accountId ?? widget.defaultAccountId,
|
||||
categoryId: _categoryId,
|
||||
merchant: draft.merchantCanonical ?? draft.merchantRaw ?? message.title,
|
||||
rawMessageId: message.id,
|
||||
),
|
||||
);
|
||||
if (txId == null || txId.isEmpty) return;
|
||||
await ref
|
||||
.read(inboxControllerProvider.notifier)
|
||||
.markApplied(message, txId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Склеенная пара «Перевод между счетами»: сумма, счёт-источник → счёт
|
||||
/// зачисления (пикеры при неразрешённых счетах), оба приложения-источника.
|
||||
/// Действия: «Подтвердить» (одна transfer-транзакция), «Расклеить»
|
||||
/// (две одиночные карточки + blocklist), «Игнорировать» (обе половинки).
|
||||
class _TransferPairBody extends ConsumerStatefulWidget {
|
||||
const _TransferPairBody({
|
||||
required this.message,
|
||||
required this.userId,
|
||||
required this.bundle,
|
||||
});
|
||||
|
||||
final RawMessage message;
|
||||
final String userId;
|
||||
final DraftBundle bundle;
|
||||
|
||||
@override
|
||||
ConsumerState<_TransferPairBody> createState() => _TransferPairBodyState();
|
||||
}
|
||||
|
||||
class _TransferPairBodyState extends ConsumerState<_TransferPairBody> {
|
||||
String? _fromId;
|
||||
String? _toId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fromId = widget.bundle.draft.accountId;
|
||||
_toId = widget.bundle.draft.transferToAccountId;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
final l10n = context.l10n;
|
||||
final draft = widget.bundle.draft;
|
||||
final secondaryId = widget.bundle.pairedRawMessageId!;
|
||||
|
||||
final accounts =
|
||||
ref.watch(accountsStreamProvider(widget.userId)).value ?? const [];
|
||||
final accountName = {for (final a in accounts) a.id: a.name};
|
||||
|
||||
final primaryApp = ref
|
||||
.watch(sourceAppLabelProvider(widget.userId, widget.message.packageName))
|
||||
.value;
|
||||
final secondary = ref.watch(pairedRawMessageProvider(secondaryId)).value;
|
||||
final secondaryApp = secondary == null
|
||||
? null
|
||||
: ref
|
||||
.watch(sourceAppLabelProvider(widget.userId, secondary.packageName))
|
||||
.value;
|
||||
final appsLine = [primaryApp, secondaryApp].nonNulls.join(' → ');
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.swap_horiz, size: 18, color: p.accent),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.inboxTransferPairTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: p.ink,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
MoneyText(draft.amount,
|
||||
color: p.ink, fontSize: 15, fontWeight: FontWeight.w700),
|
||||
],
|
||||
),
|
||||
if (appsLine.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(appsLine,
|
||||
style: TextStyle(fontSize: 13, color: p.ink2),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _AccountSlot(
|
||||
label: l10n.inboxTransferFrom,
|
||||
name: _fromId != null ? accountName[_fromId] : null,
|
||||
onTap: () => _pickAccount(isFrom: true),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Icon(Icons.arrow_forward, size: 16, color: p.ink2),
|
||||
),
|
||||
Expanded(
|
||||
child: _AccountSlot(
|
||||
label: l10n.inboxTransferTo,
|
||||
name: _toId != null ? accountName[_toId] : null,
|
||||
onTap: () => _pickAccount(isFrom: false),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.message.body,
|
||||
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: p.accent),
|
||||
onPressed: _confirm,
|
||||
child: Text(l10n.inboxConfirm),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SecondaryButton(
|
||||
icon: Icons.link_off,
|
||||
label: l10n.inboxUnpair,
|
||||
onTap: () => _runReporting(
|
||||
context,
|
||||
() => ref.read(inboxControllerProvider.notifier).unpair(
|
||||
userId: widget.userId,
|
||||
message: widget.message,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _SecondaryButton(
|
||||
icon: Icons.close,
|
||||
label: l10n.inboxIgnore,
|
||||
onTap: () => ref
|
||||
.read(inboxControllerProvider.notifier)
|
||||
.ignore(widget.message),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickAccount({required bool isFrom}) async {
|
||||
final id = await showAccountPicker(context, userId: widget.userId);
|
||||
if (id == null || !mounted) return;
|
||||
setState(() => isFrom ? _fromId = id : _toId = id);
|
||||
}
|
||||
|
||||
/// «Подтвердить»: недостающие счета запрашиваются пикером по тапу
|
||||
/// (кнопку не дизейблим), затем создаётся одна transfer-транзакция.
|
||||
Future<void> _confirm() async {
|
||||
var from = _fromId;
|
||||
if (from == null) {
|
||||
from = await showAccountPicker(context, userId: widget.userId);
|
||||
if (from == null || !mounted) return;
|
||||
setState(() => _fromId = from);
|
||||
}
|
||||
var to = _toId;
|
||||
if (to == null) {
|
||||
to = await showAccountPicker(context, userId: widget.userId);
|
||||
if (to == null || !mounted) return;
|
||||
setState(() => _toId = to);
|
||||
}
|
||||
if (from == to) return; // перевод на тот же счёт не имеет смысла
|
||||
await _runReporting(
|
||||
context,
|
||||
() => ref.read(inboxControllerProvider.notifier).confirmPair(
|
||||
userId: widget.userId,
|
||||
message: widget.message,
|
||||
draft: widget.bundle.draft,
|
||||
secondaryId: widget.bundle.pairedRawMessageId!,
|
||||
fromAccountId: from!,
|
||||
toAccountId: to!,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Слот счёта в merged-карточке: метка + имя счёта (или «выбрать»).
|
||||
class _AccountSlot extends StatelessWidget {
|
||||
const _AccountSlot({
|
||||
required this.label,
|
||||
required this.name,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String? name;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: p.paper,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: p.line),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: TextStyle(fontSize: 11, color: p.ink2)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
name ?? context.l10n.inboxTransferPickAccount,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: name != null ? p.ink : p.accent,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Пилюля быстрого выбора категории (частые категории приложения).
|
||||
class _CategoryChip extends StatelessWidget {
|
||||
const _CategoryChip({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? p.accentSoft : p.paper,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: selected ? p.accent : p.line),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: selected ? p.accent : p.ink,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UnrecognizedBody extends ConsumerWidget {
|
||||
@@ -599,21 +966,26 @@ class _FailedBody extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _CreateRuleButton extends StatelessWidget {
|
||||
const _CreateRuleButton({
|
||||
/// Акцентная сплит-кнопка «действие | ✎»: основной сегмент выполняет действие
|
||||
/// ([onTap] == null — сегмент неактивен и приглушён), карандаш всегда активен
|
||||
/// и открывает редактор/форму. Используется для «Создать правило» и
|
||||
/// «Подтвердить».
|
||||
class _SplitActionButton extends StatelessWidget {
|
||||
const _SplitActionButton({
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
required this.onEdit,
|
||||
required this.editTooltip,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback onEdit;
|
||||
final String editTooltip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
final l10n = context.l10n;
|
||||
return Material(
|
||||
color: p.accent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -623,6 +995,8 @@ class _CreateRuleButton extends StatelessWidget {
|
||||
child: InkWell(
|
||||
borderRadius: const BorderRadius.horizontal(left: Radius.circular(12)),
|
||||
onTap: onTap,
|
||||
child: Opacity(
|
||||
opacity: onTap == null ? 0.55 : 1,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
child: Text(
|
||||
@@ -638,12 +1012,13 @@ class _CreateRuleButton extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(width: 1, height: 28, color: Colors.white24),
|
||||
InkWell(
|
||||
borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)),
|
||||
onTap: onEdit,
|
||||
child: Tooltip(
|
||||
message: l10n.inboxEditRuleTooltip,
|
||||
message: editTooltip,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
child: Icon(Icons.edit_outlined, size: 18, color: Colors.white),
|
||||
|
||||
@@ -33,7 +33,6 @@ class RuleCard extends StatelessWidget {
|
||||
ParseRuleKind.merchantToCategory => Icons.storefront_outlined,
|
||||
ParseRuleKind.senderToAccount => Icons.credit_card_outlined,
|
||||
ParseRuleKind.ignore => Icons.block_outlined,
|
||||
ParseRuleKind.mixedMerchant => Icons.shuffle,
|
||||
};
|
||||
|
||||
final categoryName =
|
||||
|
||||