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 -E \"compileSdkVersion|minSdkVersion|targetSdkVersion\" \"C:\\\\\\\\Sanders\\\\\\\\FlutterSDK\\\\\\\\flutter\\\\\\\\packages\\\\\\\\flutter_tools\\\\\\\\gradle\\\\\\\\flutter.gradle\")",
|
||||||
"Bash(grep -r \"compileSdk\\\\|minSdk\\\\|targetSdk\" \"C:\\\\\\\\Sanders\\\\\\\\FlutterSDK\\\\\\\\flutter\\\\\\\\packages\\\\\\\\flutter_tools\\\\\\\\gradle\")",
|
"Bash(grep -r \"compileSdk\\\\|minSdk\\\\|targetSdk\" \"C:\\\\\\\\Sanders\\\\\\\\FlutterSDK\\\\\\\\flutter\\\\\\\\packages\\\\\\\\flutter_tools\\\\\\\\gradle\")",
|
||||||
"Bash(grep -E \"compileSdkVersion|minSdkVersion|targetSdkVersion\" \"C:\\\\\\\\Sanders\\\\\\\\FlutterSDK\\\\\\\\flutter\\\\\\\\packages\\\\\\\\flutter_tools\\\\\\\\gradle\\\\\\\\bin\\\\\\\\main\\\\\\\\FlutterExtension.kt\")",
|
"Bash(grep -E \"compileSdkVersion|minSdkVersion|targetSdkVersion\" \"C:\\\\\\\\Sanders\\\\\\\\FlutterSDK\\\\\\\\flutter\\\\\\\\packages\\\\\\\\flutter_tools\\\\\\\\gradle\\\\\\\\bin\\\\\\\\main\\\\\\\\FlutterExtension.kt\")",
|
||||||
"Bash(xargs wc -l)"
|
"Bash(xargs wc -l)",
|
||||||
|
"Bash(git -C . worktree list)",
|
||||||
|
"Bash(xargs grep -l \"class\")",
|
||||||
|
"PowerShell(dart run build_runner build --delete-conflicting-outputs)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,10 +68,34 @@ app.*.map.json
|
|||||||
*.keystore
|
*.keystore
|
||||||
|
|
||||||
# iOS / macOS
|
# iOS / macOS
|
||||||
|
# NOTE: never blanket-ignore *.xcworkspace/ — contents.xcworkspacedata and
|
||||||
|
# xcshareddata must be committed. Only per-user data (xcuserdata) is ignored.
|
||||||
|
**/ios/**/*.mode1v3
|
||||||
|
**/ios/**/*.mode2v3
|
||||||
|
**/ios/**/*.moved-aside
|
||||||
|
**/ios/**/*.pbxuser
|
||||||
|
**/ios/**/*.perspectivev3
|
||||||
|
**/ios/**/*sync/
|
||||||
|
**/ios/**/.sconsign.dblite
|
||||||
|
**/ios/**/.tags*
|
||||||
|
**/ios/**/.vagrant/
|
||||||
|
**/ios/**/DerivedData/
|
||||||
|
**/ios/**/Icon?
|
||||||
|
**/ios/**/profile
|
||||||
|
**/ios/**/xcuserdata
|
||||||
|
**/ios/.generated/
|
||||||
|
**/ios/Flutter/.last_build_id
|
||||||
|
**/ios/Flutter/App.framework
|
||||||
**/ios/Flutter/Flutter.framework
|
**/ios/Flutter/Flutter.framework
|
||||||
**/ios/Flutter/Flutter.podspec
|
**/ios/Flutter/Flutter.podspec
|
||||||
**/ios/**/*.xcworkspace/
|
**/ios/Flutter/Generated.xcconfig
|
||||||
**/macos/**/*.xcworkspace/
|
**/ios/Flutter/ephemeral/
|
||||||
|
**/ios/Flutter/app.flx
|
||||||
|
**/ios/Flutter/app.zip
|
||||||
|
**/ios/Flutter/flutter_assets/
|
||||||
|
**/ios/Flutter/flutter_export_environment.sh
|
||||||
|
**/ios/ServiceDefinitions.json
|
||||||
|
**/ios/Runner/GeneratedPluginRegistrant.*
|
||||||
Pods/
|
Pods/
|
||||||
.symlinks/
|
.symlinks/
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ migration:
|
|||||||
- platform: root
|
- platform: root
|
||||||
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
- platform: android
|
- platform: ios
|
||||||
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ lib/
|
|||||||
theme/app_colors.dart # Palette extension (paper/ink/line/accent/positive/negative)
|
theme/app_colors.dart # Palette extension (paper/ink/line/accent/positive/negative)
|
||||||
theme/theme_mode_controller.dart # @Riverpod(keepAlive) ThemeMode — in-memory for now
|
theme/theme_mode_controller.dart # @Riverpod(keepAlive) ThemeMode — in-memory for now
|
||||||
core/
|
core/
|
||||||
database/app_database.dart # @DriftDatabase, schemaVersion=8
|
database/app_database.dart # @DriftDatabase, schemaVersion=11
|
||||||
database/tables/ # users / app_preferences / settings / accounts / categories / transactions
|
database/tables/ # users / app_preferences / settings / accounts / categories / transactions
|
||||||
database/daos/ # *_dao.dart with .watch*() methods
|
database/daos/ # *_dao.dart with .watch*() methods
|
||||||
database/converters/enum_converters.dart # TypeConverter + re-exports all enums (UI imports enums from here)
|
database/converters/enum_converters.dart # TypeConverter + re-exports all enums (UI imports enums from here)
|
||||||
@@ -82,7 +82,11 @@ lib/
|
|||||||
notification_parsing/ # SMS/notification → transaction parsing (rules + AI/DeepSeek).
|
notification_parsing/ # SMS/notification → transaction parsing (rules + AI/DeepSeek).
|
||||||
# domain/data/application/presentation; Drift tables + DAOs;
|
# domain/data/application/presentation; Drift tables + DAOs;
|
||||||
# data/parser/ (dedup, confidence, decision_gate, ai_parser),
|
# data/parser/ (dedup, confidence, decision_gate, ai_parser),
|
||||||
# data/deepseek/ (client, prompts), ParsingWorker.
|
# data/deepseek/ (client, prompts), data/native/ (notification
|
||||||
|
# listener channel; Kotlin NotificationIngestService falls back to
|
||||||
|
# tickerText when extras body is empty or a "содержимое скрыто"
|
||||||
|
# stub — VTB puts the real text ONLY in ticker, no second post),
|
||||||
|
# NotificationIngestWorker + ParsingWorker.
|
||||||
# Screens: inbox, rules_list, rule_editor, parsing_settings, ai_consent, parsing_log
|
# Screens: inbox, rules_list, rule_editor, parsing_settings, ai_consent, parsing_log
|
||||||
profile/ # theme switcher screen
|
profile/ # theme switcher screen
|
||||||
shared/
|
shared/
|
||||||
@@ -104,10 +108,17 @@ Every domain table has a `userId` FK → `users`.
|
|||||||
| `categories` | id, userId, name, type(enum), iconCode, colorValue, parentId(nullable), archived |
|
| `categories` | id, userId, name, type(enum), iconCode, colorValue, parentId(nullable), archived |
|
||||||
| `transactions` | id, userId, accountId, categoryId(nullable), type(enum), amount(int), date, merchant(nullable, was `note`), extraInfo(nullable), transferToAccountId(nullable), obligation/impulse(habit enums, nullable), rawMessageId/autoApplied/appliedByRuleId (parsing), createdAt |
|
| `transactions` | id, userId, accountId, categoryId(nullable), type(enum), amount(int), date, merchant(nullable, was `note`), extraInfo(nullable), transferToAccountId(nullable), obligation/impulse(habit enums, nullable), rawMessageId/autoApplied/appliedByRuleId (parsing), createdAt |
|
||||||
|
|
||||||
**Notification-parsing tables** (in `features/notification_parsing/data/drift/`): `raw_messages`,
|
**Notification-parsing tables** (in `features/notification_parsing/data/drift/`): `raw_messages`
|
||||||
`parse_rules` (+`txType` — transaction type pinned at rule creation, checked by the gate),
|
(+`diagnostics` — full notification-extras dump, captured only while the diagnostic-mode toggle
|
||||||
`rule_candidates`, `account_bindings` (+`isDefault` per-app default binding),
|
in parsing settings is on), `parse_rules` (+`txType` — transaction type pinned at rule creation,
|
||||||
`source_apps` (allowlist of monitored apps), `transfer_pairing_blocklist`. Their enums live
|
checked by the gate; +`packageName` — rules are per-app: pipeline loads only rules of the
|
||||||
|
message's source app via `getEnabledForApp` (NULL = legacy global rows still match anywhere);
|
||||||
|
every create path must pass `packageName`),
|
||||||
|
`rule_candidates` (NOT app-scoped — key is `userId+kind+rawValue`),
|
||||||
|
`account_bindings` (+`isDefault` per-app default binding),
|
||||||
|
`source_apps` (allowlist of monitored apps; +`selfMerchant` — "merchant is the app itself",
|
||||||
|
e.g. Ozon: suppresses AI category prefill and the rule suggestion in Inbox),
|
||||||
|
`transfer_pairing_blocklist`. Their enums live
|
||||||
in `notification_parsing/domain/enums.dart`; converters in `.../data/drift/converters.dart` (both
|
in `notification_parsing/domain/enums.dart`; converters in `.../data/drift/converters.dart` (both
|
||||||
imported by `app_database.dart`).
|
imported by `app_database.dart`).
|
||||||
|
|
||||||
@@ -142,11 +153,15 @@ Do not use hard-coded color constants in widgets.
|
|||||||
|
|
||||||
Active profile is stored in `app_preferences` (key `active_user_id`), read via
|
Active profile is stored in `app_preferences` (key `active_user_id`), read via
|
||||||
`activeUserControllerProvider`. `appRouter` has a `redirect` callback + `refreshListenable`
|
`activeUserControllerProvider`. `appRouter` has a `redirect` callback + `refreshListenable`
|
||||||
on this provider: until a user exists, all routes redirect to `/onboarding`; after
|
on this provider: until a user exists, all routes redirect to `/onboarding`.
|
||||||
`usersController.createUser`, the redirect clears and `UserSeeder.seedForNewUser(userId)`
|
`onboarding_screen.dart` is a **two-step flow** (name → first account) that does no DB
|
||||||
seeds default accounts and categories. Demo transactions are **not** auto-seeded — they're
|
writes until the final submit: it calls `usersController.createUser` (which runs
|
||||||
added only via the manual "seed demo" button in `profile_screen` (temporary; remove once
|
`UserSeeder.seedForNewUser(userId)` — now seeds **default categories only**, no accounts),
|
||||||
add-transaction UX is finished).
|
then creates the user's first account via `accountsController.createAccount` and marks it
|
||||||
|
default, then `setActiveUser` (which clears the redirect → `/home`). Default **accounts** are
|
||||||
|
no longer auto-seeded. Demo accounts + transactions are added only via the manual "seed
|
||||||
|
demo" button in `profile_screen` (`seedDemoTransactionsForUser` self-heals missing
|
||||||
|
accounts/categories; temporary — remove once add-transaction UX is finished).
|
||||||
|
|
||||||
Icon/color helpers that used to live in `_mock_data.dart` now live with their features:
|
Icon/color helpers that used to live in `_mock_data.dart` now live with their features:
|
||||||
`features/categories/presentation/widgets/category_icon.dart` (`iconForCategory`,
|
`features/categories/presentation/widgets/category_icon.dart` (`iconForCategory`,
|
||||||
@@ -172,15 +187,17 @@ Icon/color helpers that used to live in `_mock_data.dart` now live with their fe
|
|||||||
- Access the container inside a widget test via
|
- Access the container inside a widget test via
|
||||||
`ProviderScope.containerOf(tester.element(find.byType(MyScreen)))` to manipulate
|
`ProviderScope.containerOf(tester.element(find.byType(MyScreen)))` to manipulate
|
||||||
notifier state after `pumpWidget`.
|
notifier state after `pumpWidget`.
|
||||||
- **`ParsingWorker` won't drain on `container.read` alone.** Its input
|
- **`ParsingWorker` inputs are direct repository stream subscriptions** (no intermediate
|
||||||
`pendingMessagesProvider` is autoDispose and only subscribes to the Drift stream when it
|
autoDispose stream providers): `container.read(parsingWorkerProvider(userId))` alone is
|
||||||
has a *direct* listener (in-app that's `HomeScreen`). In a bare `ProviderContainer` add
|
enough to drain in tests. Rationale: an internal `ref.listen` of a *paused* provider (a
|
||||||
`container.listen(pendingMessagesProvider(userId), (_, _) {}, fireImmediately: true)`
|
worker with no listeners of its own, i.e. bare-container tests) does not activate its
|
||||||
alongside reading the worker, or pending messages stay stuck at `pending`.
|
autoDispose dependencies — Riverpod 3 pause semantics. Don't reintroduce stream
|
||||||
- **Integration tests hitting real OpenRouter** live in
|
providers as worker inputs.
|
||||||
|
- **Integration tests hitting the real DeepSeek API** live in
|
||||||
`test/features/notification_parsing/integration/`, tagged `@Tags(['integration'])`. Run
|
`test/features/notification_parsing/integration/`, tagged `@Tags(['integration'])`. Run
|
||||||
with `flutter test ... --tags integration --dart-define=OPENROUTER_API_KEY=sk-or-...`
|
with `flutter test ... --tags integration --dart-define=DEEPSEEK_API_KEY=sk-...`
|
||||||
(key never hardcoded; tests `skip:` when it's absent so default `flutter test` stays
|
(optional `--dart-define=DEEPSEEK_TEST_MODEL=...`, default `kDefaultAiModel` = `deepseek-chat`;
|
||||||
|
key never hardcoded; tests `skip:` when it's absent so default `flutter test` stays
|
||||||
green/offline). Override `aiKeyStoreProvider` with a fake key store + `isOnlineProvider`
|
green/offline). Override `aiKeyStoreProvider` with a fake key store + `isOnlineProvider`
|
||||||
with `Stream.value(true)` (connectivity_plus has no binding under `flutter test`); assert
|
with `Stream.value(true)` (connectivity_plus has no binding under `flutter test`); assert
|
||||||
the terminal `RawMessageStatus` and decode `draftJson` via `decodeDraftBundle`. Drafts for
|
the terminal `RawMessageStatus` and decode `draftJson` via `decodeDraftBundle`. Drafts for
|
||||||
@@ -195,9 +212,9 @@ Icon/color helpers that used to live in `_mock_data.dart` now live with their fe
|
|||||||
(`build() => ThemeMode.dark`) and `app.dart` reads it, not settings. The `settings.themeMode`
|
(`build() => ThemeMode.dark`) and `app.dart` reads it, not settings. The `settings.themeMode`
|
||||||
column exists but is unused. Mirror `AppLocaleController` (locale is already persisted).
|
column exists but is unused. Mirror `AppLocaleController` (locale is already persisted).
|
||||||
3. Fully remove demo-transaction code from `UserSeeder` once the add-transaction flow is
|
3. Fully remove demo-transaction code from `UserSeeder` once the add-transaction flow is
|
||||||
solid. Auto-seeding on user creation is already gone (`seedForNewUser` only seeds accounts
|
solid. `seedForNewUser` now seeds **only categories** (accounts are created by the user in
|
||||||
+ categories); what remains is the manual path — `seedDemoTransactionsForUser` /
|
onboarding); what remains to remove is the manual demo path — `seedDemoTransactionsForUser`
|
||||||
`_seedDemoTransactions` + the "seed demo" button in `profile_screen`.
|
/ `_seedDemoTransactions` / `_seedAccounts` + the "seed demo" button in `profile_screen`.
|
||||||
4. `shared/formatters/` — empty; add intl money + date formatters; migrate `MoneyText` + day grouping.
|
4. `shared/formatters/` — empty; add intl money + date formatters; migrate `MoneyText` + day grouping.
|
||||||
5. Expand test coverage (currently: transactions repo/controller/form, users controller, onboarding).
|
5. Expand test coverage (currently: transactions repo/controller/form, users controller, onboarding).
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<application
|
<application
|
||||||
android:label="new_budget"
|
android:label="Kitty finances"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
@@ -27,11 +27,25 @@ class NotificationIngestService : NotificationListenerService() {
|
|||||||
val extras = notification.extras ?: return
|
val extras = notification.extras ?: return
|
||||||
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()
|
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()
|
||||||
// big-text полнее обычного text — берём его при наличии.
|
// big-text полнее обычного text — берём его при наличии.
|
||||||
val body = (extras.getCharSequence(Notification.EXTRA_BIG_TEXT)
|
val extrasBody = (extras.getCharSequence(Notification.EXTRA_BIG_TEXT)
|
||||||
?: extras.getCharSequence(Notification.EXTRA_TEXT))
|
?: extras.getCharSequence(Notification.EXTRA_TEXT))
|
||||||
?.toString()
|
?.toString()
|
||||||
?.trim()
|
?.trim()
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
|
// ВТБ прячет текст операции: в EXTRA_TEXT кладёт заглушку
|
||||||
|
// «Конфиденциальная информация в уведомлении скрыта» (реальный контент
|
||||||
|
// рисуется в custom RemoteViews и в extras не попадает), а полный текст
|
||||||
|
// («Списание 2000р Счет*3891 …») доступен только в tickerText. Второго
|
||||||
|
// поста с текстом, как у Т-Банка, ВТБ не шлёт — поэтому при пустом body
|
||||||
|
// или заглушке падаем обратно на ticker.
|
||||||
|
val ticker = notification.tickerText?.toString()?.trim().orEmpty()
|
||||||
|
val body = if ((extrasBody.isEmpty() || isHiddenContentStub(extrasBody)) &&
|
||||||
|
ticker.isNotEmpty()
|
||||||
|
) {
|
||||||
|
ticker
|
||||||
|
} else {
|
||||||
|
extrasBody
|
||||||
|
}
|
||||||
|
|
||||||
val diagnosticMode = store.isDiagnosticMode()
|
val diagnosticMode = store.isDiagnosticMode()
|
||||||
|
|
||||||
@@ -51,6 +65,12 @@ class NotificationIngestService : NotificationListenerService() {
|
|||||||
LiveSink.tick()
|
LiveSink.tick()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Заглушка «содержимое скрыто» вместо текста операции (ВТБ и т.п.). */
|
||||||
|
private fun isHiddenContentStub(body: String): Boolean {
|
||||||
|
val lower = body.lowercase()
|
||||||
|
return lower.contains("конфиденциальн") && lower.contains("скрыт")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Человекочитаемый дамп уведомления для режима диагностики: `key`,
|
* Человекочитаемый дамп уведомления для режима диагностики: `key`,
|
||||||
* `postTime`, `visibility`, `flags`, `tickerText` и все поля `extras`.
|
* `postTime`, `visibility`, `flags`, `tickerText` и все поля `extras`.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="notif_listener_label">NewBudget — распознавание уведомлений</string>
|
<string name="notif_listener_label">Kitty finances — распознавание уведомлений</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -277,10 +277,11 @@ await verifier.migrateAndValidate(db, 8);
|
|||||||
Сейчас сидер всюду фейкается; реальный код не исполняется ни одним тестом, при этом
|
Сейчас сидер всюду фейкается; реальный код не исполняется ни одним тестом, при этом
|
||||||
от него зависит первый запуск приложения.
|
от него зависит первый запуск приложения.
|
||||||
|
|
||||||
1. `seedForNewUser(userId)` → созданы дефолтные счета и категории (количества > 0;
|
1. `seedForNewUser(userId)` → созданы дефолтные **категории** (количество > 0; точные
|
||||||
точные наборы не пиннить — они будут меняться), все с правильным `userId`.
|
наборы не пиннить), все с правильным `userId`. Счета НЕ создаются и `isDefault` не
|
||||||
2. Один счёт помечен `isDefault` (если это контракт сидера — проверить по коду).
|
выставляется — первый счёт создаёт пользователь на втором шаге онбординга.
|
||||||
3. Демо-транзакции созданы и ссылаются на посеянные счета/категории (FK-цепочка цела).
|
2. `seedDemoTransactionsForUser(userId)` (ручной путь из профиля) — создаёт недостающие
|
||||||
|
счета/категории и демо-транзакции, ссылающиеся на них (FK-цепочка цела).
|
||||||
Тест оформить так, чтобы при удалении демо-сида (пункт 2 бэклога CLAUDE.md)
|
Тест оформить так, чтобы при удалении демо-сида (пункт 2 бэклога CLAUDE.md)
|
||||||
достаточно было удалить один блок ассертов.
|
достаточно было удалить один блок ассертов.
|
||||||
4. Повторный вызов для того же пользователя: пиннить фактическое поведение
|
4. Повторный вызов для того же пользователя: пиннить фактическое поведение
|
||||||
|
|||||||
@@ -147,7 +147,8 @@ unique `(userId, packageName)`. Присутствие строки = прило
|
|||||||
`accountTrusted=false` (multi-binding) → `inbox`; нет счёта → `inbox`.
|
`accountTrusted=false` (multi-binding) → `inbox`; нет счёта → `inbox`.
|
||||||
4. **Allowlist-тест** (`parsing_worker`): сообщение от пакета НЕ из включённых → `ignored`, AI не вызывается;
|
4. **Allowlist-тест** (`parsing_worker`): сообщение от пакета НЕ из включённых → `ignored`, AI не вызывается;
|
||||||
от включённого банка без карты, но с глобальным дефолтом + правилом категории → авто-применение на дефолт.
|
от включённого банка без карты, но с глобальным дефолтом + правилом категории → авто-применение на дефолт.
|
||||||
(Помнить про `container.listen(pendingMessagesProvider(userId), …)` — см. CLAUDE.md.)
|
(Устарело: `pendingMessagesProvider` удалён — воркер подписан на Drift-стримы репозитория
|
||||||
|
напрямую, в тестах достаточно `container.read(parsingWorkerProvider(userId))`.)
|
||||||
5. Существующий пакет `test/features/notification_parsing/` — зелёный.
|
5. Существующий пакет `test/features/notification_parsing/` — зелёный.
|
||||||
6. Ручная проверка (`flutter run`): добавить приложение из каталога → включить → задать привязку к счёту
|
6. Ручная проверка (`flutter run`): добавить приложение из каталога → включить → задать привязку к счёту
|
||||||
и дефолт; убедиться, что уведомление от не-включённого пакета игнорируется; создать `senderToAccount`-правило.
|
и дефолт; убедиться, что уведомление от не-включённого пакета игнорируется; создать `senderToAccount`-правило.
|
||||||
|
|||||||
@@ -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",
|
"@@locale": "en",
|
||||||
|
|
||||||
"appTitle": "NewBudget",
|
"appTitle": "Kitty finances",
|
||||||
|
|
||||||
"navHome": "Home",
|
"navHome": "Home",
|
||||||
"navAnalytics": "Analytics",
|
"navAnalytics": "Analytics",
|
||||||
@@ -84,6 +84,8 @@
|
|||||||
"onboardingNameLabel": "Your name",
|
"onboardingNameLabel": "Your name",
|
||||||
"onboardingNameHint": "How should we address you?",
|
"onboardingNameHint": "How should we address you?",
|
||||||
"onboardingContinue": "Continue",
|
"onboardingContinue": "Continue",
|
||||||
|
"onboardingAccountTitle": "Your first account",
|
||||||
|
"onboardingAccountSubtitle": "Add an account to track your money. You can add more later.",
|
||||||
|
|
||||||
"txNewTitle": "New transaction",
|
"txNewTitle": "New transaction",
|
||||||
"txEditTitle": "Edit transaction",
|
"txEditTitle": "Edit transaction",
|
||||||
@@ -208,6 +210,8 @@
|
|||||||
"parsingEnableLabel": "Recognize notifications",
|
"parsingEnableLabel": "Recognize notifications",
|
||||||
"parsingAutoApplyLabel": "Auto-add transactions",
|
"parsingAutoApplyLabel": "Auto-add transactions",
|
||||||
"parsingAutoApplyHint": "Add without confirmation when a rule exists, the amount is found in the message text and the account is trusted.",
|
"parsingAutoApplyHint": "Add without confirmation when a rule exists, the amount is found in the message text and the account is trusted.",
|
||||||
|
"parsingTransferPairingLabel": "Merge transfers between accounts",
|
||||||
|
"parsingTransferPairingHint": "Two notifications about one transfer (debit + credit) are merged into a single transfer transaction pending confirmation.",
|
||||||
"parsingDiagnosticModeLabel": "Diagnostic mode",
|
"parsingDiagnosticModeLabel": "Diagnostic mode",
|
||||||
"parsingDiagnosticModeHint": "Capture every notification from monitored apps (incl. empty/duplicate) with a full dump of its fields. Temporary — for debugging, adds noise to the parsing log.",
|
"parsingDiagnosticModeHint": "Capture every notification from monitored apps (incl. empty/duplicate) with a full dump of its fields. Temporary — for debugging, adds noise to the parsing log.",
|
||||||
"inboxWhyNotAuto": "Why not automatic: {reasons}",
|
"inboxWhyNotAuto": "Why not automatic: {reasons}",
|
||||||
@@ -247,6 +251,9 @@
|
|||||||
"parsingStatusFailed": "Failed",
|
"parsingStatusFailed": "Failed",
|
||||||
"parsingStatusPendingAi": "Waiting for AI",
|
"parsingStatusPendingAi": "Waiting for AI",
|
||||||
"parsingStatusParsedPartial": "Partial",
|
"parsingStatusParsedPartial": "Partial",
|
||||||
|
"parsingStatusWaitingPair": "Waiting for pair",
|
||||||
|
"parsingStatusPaired": "Merged into transfer",
|
||||||
|
"parsingUnmergeTransfer": "Split transfer",
|
||||||
|
|
||||||
"parsingLogOnline": "Online",
|
"parsingLogOnline": "Online",
|
||||||
"parsingLogOffline": "Offline",
|
"parsingLogOffline": "Offline",
|
||||||
@@ -294,12 +301,15 @@
|
|||||||
"@inboxCreateRule": { "placeholders": { "merchant": { "type": "String" }, "category": { "type": "String" } } },
|
"@inboxCreateRule": { "placeholders": { "merchant": { "type": "String" }, "category": { "type": "String" } } },
|
||||||
"inboxCreateRuleNoCategory": "Create rule for “{merchant}”",
|
"inboxCreateRuleNoCategory": "Create rule for “{merchant}”",
|
||||||
"@inboxCreateRuleNoCategory": { "placeholders": { "merchant": { "type": "String" } } },
|
"@inboxCreateRuleNoCategory": { "placeholders": { "merchant": { "type": "String" } } },
|
||||||
"inboxConfirmOnce": "Confirm once",
|
|
||||||
"inboxConfirm": "Confirm",
|
"inboxConfirm": "Confirm",
|
||||||
"inboxMarkMixed": "Categories vary",
|
"inboxCategoryRequiredHint": "Pick a category to confirm",
|
||||||
"inboxIgnore": "Ignore",
|
"inboxIgnore": "Ignore",
|
||||||
"inboxApplyAll": "Apply all",
|
"inboxApplyAll": "Apply all",
|
||||||
"inboxHideAll": "Hide all",
|
"inboxTransferPairTitle": "Transfer between accounts",
|
||||||
|
"inboxTransferFrom": "From account",
|
||||||
|
"inboxTransferTo": "To account",
|
||||||
|
"inboxTransferPickAccount": "Choose…",
|
||||||
|
"inboxUnpair": "Split",
|
||||||
"inboxUnrecognized": "Not recognized",
|
"inboxUnrecognized": "Not recognized",
|
||||||
"inboxParseErrorTitle": "Recognition error",
|
"inboxParseErrorTitle": "Recognition error",
|
||||||
"inboxRetry": "Try again",
|
"inboxRetry": "Try again",
|
||||||
@@ -326,13 +336,16 @@
|
|||||||
"ruleEditorModeContains": "Contains",
|
"ruleEditorModeContains": "Contains",
|
||||||
"ruleEditorModeExact": "Exact",
|
"ruleEditorModeExact": "Exact",
|
||||||
"ruleEditorModeRegex": "Regex",
|
"ruleEditorModeRegex": "Regex",
|
||||||
"ruleKindMixedHint": "No category rule is suggested for this merchant: the category is picked manually for each transaction.",
|
|
||||||
"ruleEditorThen": "Then it is:",
|
"ruleEditorThen": "Then it is:",
|
||||||
"ruleEditorMerchant": "Merchant",
|
"ruleEditorMerchant": "Merchant",
|
||||||
"ruleEditorMerchantHint": "e.g. Wildberries",
|
"ruleEditorMerchantHint": "e.g. Wildberries",
|
||||||
"ruleEditorCategory": "Category",
|
"ruleEditorCategory": "Category",
|
||||||
"ruleEditorAccount": "Account",
|
"ruleEditorAccount": "Account",
|
||||||
"ruleEditorAccountUnchanged": "— keep unchanged —",
|
"ruleEditorAccountUnchanged": "— keep unchanged —",
|
||||||
|
"ruleEditorApp": "App",
|
||||||
|
"ruleEditorAppPick": "Select app",
|
||||||
|
"ruleEditorNoApps": "Add a source app first",
|
||||||
|
"rulesUnknownApp": "Unknown app",
|
||||||
"ruleEditorAdvanced": "Advanced",
|
"ruleEditorAdvanced": "Advanced",
|
||||||
"ruleEditorPriority": "Priority",
|
"ruleEditorPriority": "Priority",
|
||||||
"ruleEditorMatchesTitle": "Matches (last 30 days)",
|
"ruleEditorMatchesTitle": "Matches (last 30 days)",
|
||||||
@@ -416,6 +429,8 @@
|
|||||||
"sourceAppsSearchHint": "Search",
|
"sourceAppsSearchHint": "Search",
|
||||||
"sourceAppsPickerEmpty": "No apps found",
|
"sourceAppsPickerEmpty": "No apps found",
|
||||||
"sourceAppsAlreadyAdded": "Already added",
|
"sourceAppsAlreadyAdded": "Already added",
|
||||||
|
"sourceAppsSelfMerchantLabel": "Merchant is the app itself",
|
||||||
|
"sourceAppsSelfMerchantHint": "Notifications never name the seller: pick the category manually, no rules are suggested",
|
||||||
|
|
||||||
"appBindingsTitle": "Account bindings",
|
"appBindingsTitle": "Account bindings",
|
||||||
"appBindingsEmpty": "No bindings yet. Add one to map a card to an account.",
|
"appBindingsEmpty": "No bindings yet. Add one to map a card to an account.",
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ abstract class AppLocalizations {
|
|||||||
/// No description provided for @appTitle.
|
/// No description provided for @appTitle.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
/// **'NewBudget'**
|
/// **'Kitty finances'**
|
||||||
String get appTitle;
|
String get appTitle;
|
||||||
|
|
||||||
/// No description provided for @navHome.
|
/// No description provided for @navHome.
|
||||||
@@ -308,6 +308,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'Продолжить'**
|
/// **'Продолжить'**
|
||||||
String get onboardingContinue;
|
String get onboardingContinue;
|
||||||
|
|
||||||
|
/// No description provided for @onboardingAccountTitle.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Ваш первый счёт'**
|
||||||
|
String get onboardingAccountTitle;
|
||||||
|
|
||||||
|
/// No description provided for @onboardingAccountSubtitle.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Добавьте счёт, чтобы учитывать деньги. Другие счета можно добавить позже.'**
|
||||||
|
String get onboardingAccountSubtitle;
|
||||||
|
|
||||||
/// No description provided for @txNewTitle.
|
/// No description provided for @txNewTitle.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
@@ -920,6 +932,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.'**
|
/// **'Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.'**
|
||||||
String get parsingAutoApplyHint;
|
String get parsingAutoApplyHint;
|
||||||
|
|
||||||
|
/// No description provided for @parsingTransferPairingLabel.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Склеивать переводы между счетами'**
|
||||||
|
String get parsingTransferPairingLabel;
|
||||||
|
|
||||||
|
/// No description provided for @parsingTransferPairingHint.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Два уведомления об одном переводе (списание + зачисление) объединяются в одну транзакцию-перевод на подтверждение.'**
|
||||||
|
String get parsingTransferPairingHint;
|
||||||
|
|
||||||
/// No description provided for @parsingDiagnosticModeLabel.
|
/// No description provided for @parsingDiagnosticModeLabel.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
@@ -1136,6 +1160,24 @@ abstract class AppLocalizations {
|
|||||||
/// **'Частично'**
|
/// **'Частично'**
|
||||||
String get parsingStatusParsedPartial;
|
String get parsingStatusParsedPartial;
|
||||||
|
|
||||||
|
/// No description provided for @parsingStatusWaitingPair.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Ждёт пару'**
|
||||||
|
String get parsingStatusWaitingPair;
|
||||||
|
|
||||||
|
/// No description provided for @parsingStatusPaired.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Объединено в перевод'**
|
||||||
|
String get parsingStatusPaired;
|
||||||
|
|
||||||
|
/// No description provided for @parsingUnmergeTransfer.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Расклеить перевод'**
|
||||||
|
String get parsingUnmergeTransfer;
|
||||||
|
|
||||||
/// No description provided for @parsingLogOnline.
|
/// No description provided for @parsingLogOnline.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
@@ -1382,23 +1424,17 @@ abstract class AppLocalizations {
|
|||||||
/// **'Создать правило для «{merchant}»'**
|
/// **'Создать правило для «{merchant}»'**
|
||||||
String inboxCreateRuleNoCategory(String merchant);
|
String inboxCreateRuleNoCategory(String merchant);
|
||||||
|
|
||||||
/// No description provided for @inboxConfirmOnce.
|
|
||||||
///
|
|
||||||
/// In ru, this message translates to:
|
|
||||||
/// **'Подтвердить разово'**
|
|
||||||
String get inboxConfirmOnce;
|
|
||||||
|
|
||||||
/// No description provided for @inboxConfirm.
|
/// No description provided for @inboxConfirm.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
/// **'Подтвердить'**
|
/// **'Подтвердить'**
|
||||||
String get inboxConfirm;
|
String get inboxConfirm;
|
||||||
|
|
||||||
/// No description provided for @inboxMarkMixed.
|
/// No description provided for @inboxCategoryRequiredHint.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
/// **'Категории различаются'**
|
/// **'Выберите категорию, чтобы подтвердить'**
|
||||||
String get inboxMarkMixed;
|
String get inboxCategoryRequiredHint;
|
||||||
|
|
||||||
/// No description provided for @inboxIgnore.
|
/// No description provided for @inboxIgnore.
|
||||||
///
|
///
|
||||||
@@ -1412,11 +1448,35 @@ abstract class AppLocalizations {
|
|||||||
/// **'Учесть все'**
|
/// **'Учесть все'**
|
||||||
String get inboxApplyAll;
|
String get inboxApplyAll;
|
||||||
|
|
||||||
/// No description provided for @inboxHideAll.
|
/// No description provided for @inboxTransferPairTitle.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
/// **'Скрыть разово'**
|
/// **'Перевод между счетами'**
|
||||||
String get inboxHideAll;
|
String get inboxTransferPairTitle;
|
||||||
|
|
||||||
|
/// No description provided for @inboxTransferFrom.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Счёт списания'**
|
||||||
|
String get inboxTransferFrom;
|
||||||
|
|
||||||
|
/// No description provided for @inboxTransferTo.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Счёт зачисления'**
|
||||||
|
String get inboxTransferTo;
|
||||||
|
|
||||||
|
/// No description provided for @inboxTransferPickAccount.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Выбрать…'**
|
||||||
|
String get inboxTransferPickAccount;
|
||||||
|
|
||||||
|
/// No description provided for @inboxUnpair.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Расклеить'**
|
||||||
|
String get inboxUnpair;
|
||||||
|
|
||||||
/// No description provided for @inboxUnrecognized.
|
/// No description provided for @inboxUnrecognized.
|
||||||
///
|
///
|
||||||
@@ -1556,12 +1616,6 @@ abstract class AppLocalizations {
|
|||||||
/// **'Regex'**
|
/// **'Regex'**
|
||||||
String get ruleEditorModeRegex;
|
String get ruleEditorModeRegex;
|
||||||
|
|
||||||
/// No description provided for @ruleKindMixedHint.
|
|
||||||
///
|
|
||||||
/// In ru, this message translates to:
|
|
||||||
/// **'Для этого мерчанта правила-категории не предлагаются: категория выбирается вручную для каждой операции.'**
|
|
||||||
String get ruleKindMixedHint;
|
|
||||||
|
|
||||||
/// No description provided for @ruleEditorThen.
|
/// No description provided for @ruleEditorThen.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
@@ -1598,6 +1652,30 @@ abstract class AppLocalizations {
|
|||||||
/// **'— не менять —'**
|
/// **'— не менять —'**
|
||||||
String get ruleEditorAccountUnchanged;
|
String get ruleEditorAccountUnchanged;
|
||||||
|
|
||||||
|
/// No description provided for @ruleEditorApp.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Приложение'**
|
||||||
|
String get ruleEditorApp;
|
||||||
|
|
||||||
|
/// No description provided for @ruleEditorAppPick.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Выберите приложение'**
|
||||||
|
String get ruleEditorAppPick;
|
||||||
|
|
||||||
|
/// No description provided for @ruleEditorNoApps.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Сначала добавьте приложение-источник'**
|
||||||
|
String get ruleEditorNoApps;
|
||||||
|
|
||||||
|
/// No description provided for @rulesUnknownApp.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Неизвестное приложение'**
|
||||||
|
String get rulesUnknownApp;
|
||||||
|
|
||||||
/// No description provided for @ruleEditorAdvanced.
|
/// No description provided for @ruleEditorAdvanced.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
@@ -1946,6 +2024,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'Уже добавлено'**
|
/// **'Уже добавлено'**
|
||||||
String get sourceAppsAlreadyAdded;
|
String get sourceAppsAlreadyAdded;
|
||||||
|
|
||||||
|
/// No description provided for @sourceAppsSelfMerchantLabel.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Мерчант — само приложение'**
|
||||||
|
String get sourceAppsSelfMerchantLabel;
|
||||||
|
|
||||||
|
/// No description provided for @sourceAppsSelfMerchantHint.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Уведомления не называют продавца: категория выбирается вручную, правила не предлагаются'**
|
||||||
|
String get sourceAppsSelfMerchantHint;
|
||||||
|
|
||||||
/// No description provided for @appBindingsTitle.
|
/// No description provided for @appBindingsTitle.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
AppLocalizationsEn([String locale = 'en']) : super(locale);
|
AppLocalizationsEn([String locale = 'en']) : super(locale);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appTitle => 'NewBudget';
|
String get appTitle => 'Kitty finances';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navHome => 'Home';
|
String get navHome => 'Home';
|
||||||
@@ -142,6 +142,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get onboardingContinue => 'Continue';
|
String get onboardingContinue => 'Continue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get onboardingAccountTitle => 'Your first account';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get onboardingAccountSubtitle =>
|
||||||
|
'Add an account to track your money. You can add more later.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get txNewTitle => 'New transaction';
|
String get txNewTitle => 'New transaction';
|
||||||
|
|
||||||
@@ -475,6 +482,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get parsingAutoApplyHint =>
|
String get parsingAutoApplyHint =>
|
||||||
'Add without confirmation when a rule exists, the amount is found in the message text and the account is trusted.';
|
'Add without confirmation when a rule exists, the amount is found in the message text and the account is trusted.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get parsingTransferPairingLabel => 'Merge transfers between accounts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get parsingTransferPairingHint =>
|
||||||
|
'Two notifications about one transfer (debit + credit) are merged into a single transfer transaction pending confirmation.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get parsingDiagnosticModeLabel => 'Diagnostic mode';
|
String get parsingDiagnosticModeLabel => 'Diagnostic mode';
|
||||||
|
|
||||||
@@ -593,6 +607,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get parsingStatusParsedPartial => 'Partial';
|
String get parsingStatusParsedPartial => 'Partial';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get parsingStatusWaitingPair => 'Waiting for pair';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get parsingStatusPaired => 'Merged into transfer';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get parsingUnmergeTransfer => 'Split transfer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get parsingLogOnline => 'Online';
|
String get parsingLogOnline => 'Online';
|
||||||
|
|
||||||
@@ -724,14 +747,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return 'Create rule for “$merchant”';
|
return 'Create rule for “$merchant”';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get inboxConfirmOnce => 'Confirm once';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get inboxConfirm => 'Confirm';
|
String get inboxConfirm => 'Confirm';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get inboxMarkMixed => 'Categories vary';
|
String get inboxCategoryRequiredHint => 'Pick a category to confirm';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get inboxIgnore => 'Ignore';
|
String get inboxIgnore => 'Ignore';
|
||||||
@@ -740,7 +760,19 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get inboxApplyAll => 'Apply all';
|
String get inboxApplyAll => 'Apply all';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get inboxHideAll => 'Hide all';
|
String get inboxTransferPairTitle => 'Transfer between accounts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inboxTransferFrom => 'From account';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inboxTransferTo => 'To account';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inboxTransferPickAccount => 'Choose…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inboxUnpair => 'Split';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get inboxUnrecognized => 'Not recognized';
|
String get inboxUnrecognized => 'Not recognized';
|
||||||
@@ -820,10 +852,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get ruleEditorModeRegex => 'Regex';
|
String get ruleEditorModeRegex => 'Regex';
|
||||||
|
|
||||||
@override
|
|
||||||
String get ruleKindMixedHint =>
|
|
||||||
'No category rule is suggested for this merchant: the category is picked manually for each transaction.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get ruleEditorThen => 'Then it is:';
|
String get ruleEditorThen => 'Then it is:';
|
||||||
|
|
||||||
@@ -842,6 +870,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get ruleEditorAccountUnchanged => '— keep unchanged —';
|
String get ruleEditorAccountUnchanged => '— keep unchanged —';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ruleEditorApp => 'App';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ruleEditorAppPick => 'Select app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ruleEditorNoApps => 'Add a source app first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rulesUnknownApp => 'Unknown app';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get ruleEditorAdvanced => 'Advanced';
|
String get ruleEditorAdvanced => 'Advanced';
|
||||||
|
|
||||||
@@ -1022,6 +1062,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sourceAppsAlreadyAdded => 'Already added';
|
String get sourceAppsAlreadyAdded => 'Already added';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceAppsSelfMerchantLabel => 'Merchant is the app itself';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceAppsSelfMerchantHint =>
|
||||||
|
'Notifications never name the seller: pick the category manually, no rules are suggested';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appBindingsTitle => 'Account bindings';
|
String get appBindingsTitle => 'Account bindings';
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
AppLocalizationsRu([String locale = 'ru']) : super(locale);
|
AppLocalizationsRu([String locale = 'ru']) : super(locale);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appTitle => 'NewBudget';
|
String get appTitle => 'Kitty finances';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navHome => 'Главная';
|
String get navHome => 'Главная';
|
||||||
@@ -148,6 +148,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get onboardingContinue => 'Продолжить';
|
String get onboardingContinue => 'Продолжить';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get onboardingAccountTitle => 'Ваш первый счёт';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get onboardingAccountSubtitle =>
|
||||||
|
'Добавьте счёт, чтобы учитывать деньги. Другие счета можно добавить позже.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get txNewTitle => 'Новая операция';
|
String get txNewTitle => 'Новая операция';
|
||||||
|
|
||||||
@@ -487,6 +494,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get parsingAutoApplyHint =>
|
String get parsingAutoApplyHint =>
|
||||||
'Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.';
|
'Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get parsingTransferPairingLabel => 'Склеивать переводы между счетами';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get parsingTransferPairingHint =>
|
||||||
|
'Два уведомления об одном переводе (списание + зачисление) объединяются в одну транзакцию-перевод на подтверждение.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get parsingDiagnosticModeLabel => 'Режим диагностики';
|
String get parsingDiagnosticModeLabel => 'Режим диагностики';
|
||||||
|
|
||||||
@@ -604,6 +618,15 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get parsingStatusParsedPartial => 'Частично';
|
String get parsingStatusParsedPartial => 'Частично';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get parsingStatusWaitingPair => 'Ждёт пару';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get parsingStatusPaired => 'Объединено в перевод';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get parsingUnmergeTransfer => 'Расклеить перевод';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get parsingLogOnline => 'Онлайн';
|
String get parsingLogOnline => 'Онлайн';
|
||||||
|
|
||||||
@@ -734,14 +757,12 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
return 'Создать правило для «$merchant»';
|
return 'Создать правило для «$merchant»';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get inboxConfirmOnce => 'Подтвердить разово';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get inboxConfirm => 'Подтвердить';
|
String get inboxConfirm => 'Подтвердить';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get inboxMarkMixed => 'Категории различаются';
|
String get inboxCategoryRequiredHint =>
|
||||||
|
'Выберите категорию, чтобы подтвердить';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get inboxIgnore => 'Игнорировать';
|
String get inboxIgnore => 'Игнорировать';
|
||||||
@@ -750,7 +771,19 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get inboxApplyAll => 'Учесть все';
|
String get inboxApplyAll => 'Учесть все';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get inboxHideAll => 'Скрыть разово';
|
String get inboxTransferPairTitle => 'Перевод между счетами';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inboxTransferFrom => 'Счёт списания';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inboxTransferTo => 'Счёт зачисления';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inboxTransferPickAccount => 'Выбрать…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inboxUnpair => 'Расклеить';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get inboxUnrecognized => 'Не распознано';
|
String get inboxUnrecognized => 'Не распознано';
|
||||||
@@ -832,10 +865,6 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get ruleEditorModeRegex => 'Regex';
|
String get ruleEditorModeRegex => 'Regex';
|
||||||
|
|
||||||
@override
|
|
||||||
String get ruleKindMixedHint =>
|
|
||||||
'Для этого мерчанта правила-категории не предлагаются: категория выбирается вручную для каждой операции.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get ruleEditorThen => 'То это:';
|
String get ruleEditorThen => 'То это:';
|
||||||
|
|
||||||
@@ -854,6 +883,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get ruleEditorAccountUnchanged => '— не менять —';
|
String get ruleEditorAccountUnchanged => '— не менять —';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ruleEditorApp => 'Приложение';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ruleEditorAppPick => 'Выберите приложение';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ruleEditorNoApps => 'Сначала добавьте приложение-источник';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rulesUnknownApp => 'Неизвестное приложение';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get ruleEditorAdvanced => 'Дополнительно';
|
String get ruleEditorAdvanced => 'Дополнительно';
|
||||||
|
|
||||||
@@ -1034,6 +1075,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sourceAppsAlreadyAdded => 'Уже добавлено';
|
String get sourceAppsAlreadyAdded => 'Уже добавлено';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceAppsSelfMerchantLabel => 'Мерчант — само приложение';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sourceAppsSelfMerchantHint =>
|
||||||
|
'Уведомления не называют продавца: категория выбирается вручную, правила не предлагаются';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appBindingsTitle => 'Привязки счетов';
|
String get appBindingsTitle => 'Привязки счетов';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"@@locale": "ru",
|
"@@locale": "ru",
|
||||||
|
|
||||||
"appTitle": "NewBudget",
|
"appTitle": "Kitty finances",
|
||||||
|
|
||||||
"navHome": "Главная",
|
"navHome": "Главная",
|
||||||
"navAnalytics": "Аналитика",
|
"navAnalytics": "Аналитика",
|
||||||
@@ -84,6 +84,8 @@
|
|||||||
"onboardingNameLabel": "Ваше имя",
|
"onboardingNameLabel": "Ваше имя",
|
||||||
"onboardingNameHint": "Например, Алекс",
|
"onboardingNameHint": "Например, Алекс",
|
||||||
"onboardingContinue": "Продолжить",
|
"onboardingContinue": "Продолжить",
|
||||||
|
"onboardingAccountTitle": "Ваш первый счёт",
|
||||||
|
"onboardingAccountSubtitle": "Добавьте счёт, чтобы учитывать деньги. Другие счета можно добавить позже.",
|
||||||
|
|
||||||
"txNewTitle": "Новая операция",
|
"txNewTitle": "Новая операция",
|
||||||
"txEditTitle": "Редактировать операцию",
|
"txEditTitle": "Редактировать операцию",
|
||||||
@@ -208,6 +210,8 @@
|
|||||||
"parsingEnableLabel": "Распознавать уведомления",
|
"parsingEnableLabel": "Распознавать уведомления",
|
||||||
"parsingAutoApplyLabel": "Авто-добавление транзакций",
|
"parsingAutoApplyLabel": "Авто-добавление транзакций",
|
||||||
"parsingAutoApplyHint": "Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.",
|
"parsingAutoApplyHint": "Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.",
|
||||||
|
"parsingTransferPairingLabel": "Склеивать переводы между счетами",
|
||||||
|
"parsingTransferPairingHint": "Два уведомления об одном переводе (списание + зачисление) объединяются в одну транзакцию-перевод на подтверждение.",
|
||||||
"parsingDiagnosticModeLabel": "Режим диагностики",
|
"parsingDiagnosticModeLabel": "Режим диагностики",
|
||||||
"parsingDiagnosticModeHint": "Ловить каждое уведомление от мониторимых приложений (включая пустые/повторные) с полным дампом всех полей. Временно — для отладки, добавляет шум в журнал парсинга.",
|
"parsingDiagnosticModeHint": "Ловить каждое уведомление от мониторимых приложений (включая пустые/повторные) с полным дампом всех полей. Временно — для отладки, добавляет шум в журнал парсинга.",
|
||||||
"inboxWhyNotAuto": "Почему не автоматически: {reasons}",
|
"inboxWhyNotAuto": "Почему не автоматически: {reasons}",
|
||||||
@@ -247,6 +251,9 @@
|
|||||||
"parsingStatusFailed": "Ошибка",
|
"parsingStatusFailed": "Ошибка",
|
||||||
"parsingStatusPendingAi": "Ждёт AI",
|
"parsingStatusPendingAi": "Ждёт AI",
|
||||||
"parsingStatusParsedPartial": "Частично",
|
"parsingStatusParsedPartial": "Частично",
|
||||||
|
"parsingStatusWaitingPair": "Ждёт пару",
|
||||||
|
"parsingStatusPaired": "Объединено в перевод",
|
||||||
|
"parsingUnmergeTransfer": "Расклеить перевод",
|
||||||
|
|
||||||
"parsingLogOnline": "Онлайн",
|
"parsingLogOnline": "Онлайн",
|
||||||
"parsingLogOffline": "Офлайн",
|
"parsingLogOffline": "Офлайн",
|
||||||
@@ -294,12 +301,15 @@
|
|||||||
"@inboxCreateRule": { "placeholders": { "merchant": { "type": "String" }, "category": { "type": "String" } } },
|
"@inboxCreateRule": { "placeholders": { "merchant": { "type": "String" }, "category": { "type": "String" } } },
|
||||||
"inboxCreateRuleNoCategory": "Создать правило для «{merchant}»",
|
"inboxCreateRuleNoCategory": "Создать правило для «{merchant}»",
|
||||||
"@inboxCreateRuleNoCategory": { "placeholders": { "merchant": { "type": "String" } } },
|
"@inboxCreateRuleNoCategory": { "placeholders": { "merchant": { "type": "String" } } },
|
||||||
"inboxConfirmOnce": "Подтвердить разово",
|
|
||||||
"inboxConfirm": "Подтвердить",
|
"inboxConfirm": "Подтвердить",
|
||||||
"inboxMarkMixed": "Категории различаются",
|
"inboxCategoryRequiredHint": "Выберите категорию, чтобы подтвердить",
|
||||||
"inboxIgnore": "Игнорировать",
|
"inboxIgnore": "Игнорировать",
|
||||||
"inboxApplyAll": "Учесть все",
|
"inboxApplyAll": "Учесть все",
|
||||||
"inboxHideAll": "Скрыть разово",
|
"inboxTransferPairTitle": "Перевод между счетами",
|
||||||
|
"inboxTransferFrom": "Счёт списания",
|
||||||
|
"inboxTransferTo": "Счёт зачисления",
|
||||||
|
"inboxTransferPickAccount": "Выбрать…",
|
||||||
|
"inboxUnpair": "Расклеить",
|
||||||
"inboxUnrecognized": "Не распознано",
|
"inboxUnrecognized": "Не распознано",
|
||||||
"inboxParseErrorTitle": "Ошибка распознавания",
|
"inboxParseErrorTitle": "Ошибка распознавания",
|
||||||
"inboxRetry": "Попробовать снова",
|
"inboxRetry": "Попробовать снова",
|
||||||
@@ -326,13 +336,16 @@
|
|||||||
"ruleEditorModeContains": "Содержит",
|
"ruleEditorModeContains": "Содержит",
|
||||||
"ruleEditorModeExact": "Точно",
|
"ruleEditorModeExact": "Точно",
|
||||||
"ruleEditorModeRegex": "Regex",
|
"ruleEditorModeRegex": "Regex",
|
||||||
"ruleKindMixedHint": "Для этого мерчанта правила-категории не предлагаются: категория выбирается вручную для каждой операции.",
|
|
||||||
"ruleEditorThen": "То это:",
|
"ruleEditorThen": "То это:",
|
||||||
"ruleEditorMerchant": "Мерчант",
|
"ruleEditorMerchant": "Мерчант",
|
||||||
"ruleEditorMerchantHint": "Напр. Wildberries",
|
"ruleEditorMerchantHint": "Напр. Wildberries",
|
||||||
"ruleEditorCategory": "Категория",
|
"ruleEditorCategory": "Категория",
|
||||||
"ruleEditorAccount": "Счёт",
|
"ruleEditorAccount": "Счёт",
|
||||||
"ruleEditorAccountUnchanged": "— не менять —",
|
"ruleEditorAccountUnchanged": "— не менять —",
|
||||||
|
"ruleEditorApp": "Приложение",
|
||||||
|
"ruleEditorAppPick": "Выберите приложение",
|
||||||
|
"ruleEditorNoApps": "Сначала добавьте приложение-источник",
|
||||||
|
"rulesUnknownApp": "Неизвестное приложение",
|
||||||
"ruleEditorAdvanced": "Дополнительно",
|
"ruleEditorAdvanced": "Дополнительно",
|
||||||
"ruleEditorPriority": "Приоритет",
|
"ruleEditorPriority": "Приоритет",
|
||||||
"ruleEditorMatchesTitle": "Совпадает с (последние 30 дней)",
|
"ruleEditorMatchesTitle": "Совпадает с (последние 30 дней)",
|
||||||
@@ -416,6 +429,8 @@
|
|||||||
"sourceAppsSearchHint": "Поиск",
|
"sourceAppsSearchHint": "Поиск",
|
||||||
"sourceAppsPickerEmpty": "Приложения не найдены",
|
"sourceAppsPickerEmpty": "Приложения не найдены",
|
||||||
"sourceAppsAlreadyAdded": "Уже добавлено",
|
"sourceAppsAlreadyAdded": "Уже добавлено",
|
||||||
|
"sourceAppsSelfMerchantLabel": "Мерчант — само приложение",
|
||||||
|
"sourceAppsSelfMerchantHint": "Уведомления не называют продавца: категория выбирается вручную, правила не предлагаются",
|
||||||
|
|
||||||
"appBindingsTitle": "Привязки счетов",
|
"appBindingsTitle": "Привязки счетов",
|
||||||
"appBindingsEmpty": "Пока нет привязок. Добавьте, чтобы связать карту со счётом.",
|
"appBindingsEmpty": "Пока нет привязок. Добавьте, чтобы связать карту со счётом.",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class NewBudgetApp extends ConsumerWidget {
|
|||||||
final locale = ref.watch(appLocaleControllerProvider);
|
final locale = ref.watch(appLocaleControllerProvider);
|
||||||
|
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: 'NewBudget',
|
title: 'Kitty finances',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.light(),
|
theme: AppTheme.light(),
|
||||||
darkTheme: AppTheme.dark(),
|
darkTheme: AppTheme.dark(),
|
||||||
|
|||||||
@@ -59,9 +59,11 @@ GoRouter appRouter(Ref ref) {
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.transactionNew,
|
path: AppRoutes.transactionNew,
|
||||||
pageBuilder: (context, state) => _slideUpPage<void>(
|
pageBuilder: (context, state) => _slideUpPage<String?>(
|
||||||
state,
|
state,
|
||||||
const TransactionFormScreen(),
|
TransactionFormScreen(
|
||||||
|
prefill: state.extra as TransactionFormPrefill?,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import '../../features/notification_parsing/data/drift/daos/parse_rules_dao.dart
|
|||||||
import '../../features/notification_parsing/data/drift/daos/rule_candidates_dao.dart';
|
import '../../features/notification_parsing/data/drift/daos/rule_candidates_dao.dart';
|
||||||
import '../../features/notification_parsing/data/drift/daos/account_bindings_dao.dart';
|
import '../../features/notification_parsing/data/drift/daos/account_bindings_dao.dart';
|
||||||
import '../../features/notification_parsing/data/drift/daos/source_apps_dao.dart';
|
import '../../features/notification_parsing/data/drift/daos/source_apps_dao.dart';
|
||||||
|
import '../../features/notification_parsing/data/drift/daos/transfer_pairing_blocklist_dao.dart';
|
||||||
|
|
||||||
part 'app_database.g.dart';
|
part 'app_database.g.dart';
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ part 'app_database.g.dart';
|
|||||||
RuleCandidatesDao,
|
RuleCandidatesDao,
|
||||||
AccountBindingsDao,
|
AccountBindingsDao,
|
||||||
SourceAppsDao,
|
SourceAppsDao,
|
||||||
|
TransferPairingBlocklistDao,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
@@ -68,74 +70,13 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
AppDatabase.forTesting(super.executor);
|
AppDatabase.forTesting(super.executor);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 9;
|
int get schemaVersion => 1;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
onCreate: (m) async {
|
onCreate: (m) async {
|
||||||
await m.createAll();
|
await m.createAll();
|
||||||
},
|
},
|
||||||
onUpgrade: (m, from, to) async {
|
|
||||||
if (from < 2) {
|
|
||||||
// v1 → v2: PK сменились с int autoIncrement на UUID text.
|
|
||||||
await m.drop(transactionsTable);
|
|
||||||
await m.drop(categoriesTable);
|
|
||||||
await m.drop(accountsTable);
|
|
||||||
await m.drop(settingsTable);
|
|
||||||
await m.drop(usersTable);
|
|
||||||
await m.createAll();
|
|
||||||
}
|
|
||||||
if (from < 3) {
|
|
||||||
// v2 → v3: добавлено поле extra_info в transactions.
|
|
||||||
await m.addColumn(transactionsTable, transactionsTable.extraInfo);
|
|
||||||
}
|
|
||||||
if (from < 4) {
|
|
||||||
// v3 → v4: добавлено поле is_default в accounts.
|
|
||||||
await m.addColumn(accountsTable, accountsTable.isDefault);
|
|
||||||
}
|
|
||||||
if (from < 5) {
|
|
||||||
// v4 → v5: notification-parsing schema.
|
|
||||||
//
|
|
||||||
// Transactions: note → merchant + новые поля парсинга.
|
|
||||||
await m.renameColumn(
|
|
||||||
transactionsTable, 'note', transactionsTable.merchant);
|
|
||||||
await m.addColumn(
|
|
||||||
transactionsTable, transactionsTable.rawMessageId);
|
|
||||||
await m.addColumn(
|
|
||||||
transactionsTable, transactionsTable.autoApplied);
|
|
||||||
await m.addColumn(
|
|
||||||
transactionsTable, transactionsTable.appliedByRuleId);
|
|
||||||
// Новые таблицы:
|
|
||||||
await m.createTable(rawMessagesTable);
|
|
||||||
await m.createTable(parseRulesTable);
|
|
||||||
await m.createTable(ruleCandidatesTable);
|
|
||||||
await m.createTable(accountBindingsTable);
|
|
||||||
await m.createTable(transferPairingBlocklistTable);
|
|
||||||
}
|
|
||||||
if (from < 6) {
|
|
||||||
// v5 → v6: habit-tracking поля.
|
|
||||||
await m.addColumn(
|
|
||||||
transactionsTable, transactionsTable.obligation);
|
|
||||||
await m.addColumn(transactionsTable, transactionsTable.impulse);
|
|
||||||
await m.addColumn(
|
|
||||||
settingsTable, settingsTable.habitTrackingEnabled);
|
|
||||||
}
|
|
||||||
if (from < 7) {
|
|
||||||
// v6 → v7: умолчательные привязки + allowlist приложений-источников.
|
|
||||||
await m.addColumn(
|
|
||||||
accountBindingsTable, accountBindingsTable.isDefault);
|
|
||||||
await m.createTable(sourceAppsTable);
|
|
||||||
}
|
|
||||||
if (from < 8) {
|
|
||||||
// v7 → v8: тип операции фиксируется в правиле (для gate-проверки
|
|
||||||
// typeMatchesRule).
|
|
||||||
await m.addColumn(parseRulesTable, parseRulesTable.txType);
|
|
||||||
}
|
|
||||||
if (from < 9) {
|
|
||||||
// v8 → v9: дамп нативного уведомления для режима диагностики.
|
|
||||||
await m.addColumn(rawMessagesTable, rawMessagesTable.diagnostics);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
static QueryExecutor _openConnection() {
|
static QueryExecutor _openConnection() {
|
||||||
|
|||||||
@@ -11,28 +11,31 @@ part 'habit_analysis_providers.g.dart';
|
|||||||
// Состояние фильтров (autoDispose)
|
// Состояние фильтров (autoDispose)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Фильтр по импульсивности: `null` = «Все».
|
/// Фильтр по импульсивности (главная шкала каскада): `null` = «Все».
|
||||||
@riverpod
|
@riverpod
|
||||||
class HabitImpulseFilter extends _$HabitImpulseFilter {
|
class HabitImpulseFilter extends _$HabitImpulseFilter {
|
||||||
@override
|
@override
|
||||||
SpendingImpulse? build() => null;
|
SpendingImpulse? build() => null;
|
||||||
|
|
||||||
void select(SpendingImpulse? value) => state = value;
|
void select(SpendingImpulse? value) {
|
||||||
|
// У «Обязательно» импульсивности не бывает (инвариант формы транзакции) —
|
||||||
|
// активный импульс-фильтр несовместим с этим значением обязательности.
|
||||||
|
if (value != null &&
|
||||||
|
ref.read(habitObligationFilterProvider) ==
|
||||||
|
SpendingObligation.required) {
|
||||||
|
ref.read(habitObligationFilterProvider.notifier).select(null);
|
||||||
|
}
|
||||||
|
state = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Фильтр по обязательности (мульти-выбор). Пустое множество = «Все».
|
/// Фильтр по обязательности (вторичная шкала): `null` = «Все».
|
||||||
@riverpod
|
@riverpod
|
||||||
class HabitObligationFilter extends _$HabitObligationFilter {
|
class HabitObligationFilter extends _$HabitObligationFilter {
|
||||||
@override
|
@override
|
||||||
Set<SpendingObligation> build() => const {};
|
SpendingObligation? build() => null;
|
||||||
|
|
||||||
void toggle(SpendingObligation value) {
|
void select(SpendingObligation? value) => state = value;
|
||||||
final next = Set<SpendingObligation>.from(state);
|
|
||||||
if (!next.add(value)) next.remove(value);
|
|
||||||
state = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
void clear() => state = const {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -71,12 +74,16 @@ Map<SpendingImpulse?, int> habitSumByImpulse(Ref ref, String userId) {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Суммы расходов по обязательности (включая ключ `null` = «без оценки»).
|
/// Суммы расходов по обязательности (включая ключ `null` = «без оценки»)
|
||||||
|
/// внутри выборки активного импульс-фильтра: вторая строка каскада
|
||||||
|
/// показывает разбивку выбранного импульс-сегмента.
|
||||||
@riverpod
|
@riverpod
|
||||||
Map<SpendingObligation?, int> habitSumByObligation(Ref ref, String userId) {
|
Map<SpendingObligation?, int> habitSumByObligation(Ref ref, String userId) {
|
||||||
final txs = ref.watch(habitMonthTransactionsProvider(userId));
|
final txs = ref.watch(habitMonthTransactionsProvider(userId));
|
||||||
|
final impulse = ref.watch(habitImpulseFilterProvider);
|
||||||
final map = <SpendingObligation?, int>{};
|
final map = <SpendingObligation?, int>{};
|
||||||
for (final t in txs) {
|
for (final t in txs) {
|
||||||
|
if (impulse != null && t.impulse != impulse) continue;
|
||||||
map[t.obligation] = (map[t.obligation] ?? 0) + t.amount;
|
map[t.obligation] = (map[t.obligation] ?? 0) + t.amount;
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
@@ -87,14 +94,11 @@ Map<SpendingObligation?, int> habitSumByObligation(Ref ref, String userId) {
|
|||||||
List<Transaction> habitFilteredTransactions(Ref ref, String userId) {
|
List<Transaction> habitFilteredTransactions(Ref ref, String userId) {
|
||||||
final txs = ref.watch(habitMonthTransactionsProvider(userId));
|
final txs = ref.watch(habitMonthTransactionsProvider(userId));
|
||||||
final impulse = ref.watch(habitImpulseFilterProvider);
|
final impulse = ref.watch(habitImpulseFilterProvider);
|
||||||
final obligations = ref.watch(habitObligationFilterProvider);
|
final obligation = ref.watch(habitObligationFilterProvider);
|
||||||
|
|
||||||
return txs.where((t) {
|
return txs.where((t) {
|
||||||
if (impulse != null && t.impulse != impulse) return false;
|
if (impulse != null && t.impulse != impulse) return false;
|
||||||
if (obligations.isNotEmpty &&
|
if (obligation != null && t.obligation != obligation) return false;
|
||||||
(t.obligation == null || !obligations.contains(t.obligation))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,10 +110,15 @@ class _HeaderBar extends ConsumerWidget {
|
|||||||
final monthTitle = '$monthCap ${month.year}';
|
final monthTitle = '$monthCap ${month.year}';
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
|
padding: const EdgeInsets.fromLTRB(4, 8, 16, 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: Icon(Icons.close, color: p.ink),
|
||||||
|
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
l10n.habitAnalysisTitle,
|
l10n.habitAnalysisTitle,
|
||||||
@@ -197,6 +202,14 @@ class _FilterBlock extends ConsumerWidget {
|
|||||||
|
|
||||||
final totalImpulse =
|
final totalImpulse =
|
||||||
impulseSums.values.fold<int>(0, (s, v) => s + v);
|
impulseSums.values.fold<int>(0, (s, v) => s + v);
|
||||||
|
final totalObligation =
|
||||||
|
obligationSums.values.fold<int>(0, (s, v) => s + v);
|
||||||
|
// «Обязательно» несовместимо с импульс-фильтром (инвариант формы).
|
||||||
|
final obligationValues = impulseFilter == null
|
||||||
|
? SpendingObligation.values
|
||||||
|
: SpendingObligation.values
|
||||||
|
.where((o) => o != SpendingObligation.required)
|
||||||
|
.toList();
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||||
@@ -242,24 +255,38 @@ class _FilterBlock extends ConsumerWidget {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_ScaleCaption(l10n.habitScaleObligationCaps),
|
_ScaleCaption(l10n.habitScaleObligationCaps),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Row(
|
AnimatedSize(
|
||||||
children: [
|
duration: const Duration(milliseconds: 200),
|
||||||
for (final o in SpendingObligation.values) ...[
|
curve: Curves.easeInOut,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _ObligationChip(
|
child: _ObligationChip(
|
||||||
title: _obligationFilterLabel(l10n, o),
|
title: l10n.habitFilterAll,
|
||||||
sumMinor: obligationSums[o] ?? 0,
|
sumMinor: totalObligation,
|
||||||
dotColor: _dotColor(hc, o),
|
selected: obligationFilter == null,
|
||||||
selected: obligationFilter.contains(o),
|
|
||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(habitObligationFilterProvider.notifier)
|
.read(habitObligationFilterProvider.notifier)
|
||||||
.toggle(o),
|
.select(null),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (o != SpendingObligation.values.last)
|
for (final o in obligationValues) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _ObligationChip(
|
||||||
|
title: _obligationFilterLabel(l10n, o),
|
||||||
|
sumMinor: obligationSums[o] ?? 0,
|
||||||
|
dotColor: _dotColor(hc, o),
|
||||||
|
selected: obligationFilter == o,
|
||||||
|
onTap: () => ref
|
||||||
|
.read(habitObligationFilterProvider.notifier)
|
||||||
|
.select(obligationFilter == o ? null : o),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Divider(height: 1, color: p.line),
|
Divider(height: 1, color: p.line),
|
||||||
@@ -360,14 +387,14 @@ class _ObligationChip extends StatelessWidget {
|
|||||||
const _ObligationChip({
|
const _ObligationChip({
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.sumMinor,
|
required this.sumMinor,
|
||||||
required this.dotColor,
|
|
||||||
required this.selected,
|
required this.selected,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
this.dotColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
final int sumMinor;
|
final int sumMinor;
|
||||||
final Color dotColor;
|
final Color? dotColor;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@@ -392,13 +419,15 @@ class _ObligationChip extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
if (dotColor != null) ...[
|
||||||
width: 8,
|
Container(
|
||||||
height: 8,
|
width: 8,
|
||||||
decoration:
|
height: 8,
|
||||||
BoxDecoration(color: dotColor, shape: BoxShape.circle),
|
decoration:
|
||||||
),
|
BoxDecoration(color: dotColor, shape: BoxShape.circle),
|
||||||
const SizedBox(width: 6),
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
],
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
import '../../../core/database/converters/enum_converters.dart';
|
||||||
import '../../accounts/application/account_providers.dart';
|
import '../../accounts/application/account_providers.dart';
|
||||||
|
import '../../transactions/application/transaction_providers.dart';
|
||||||
import '../../transactions/application/transactions_controller.dart';
|
import '../../transactions/application/transactions_controller.dart';
|
||||||
import '../data/parser/draft_codec.dart';
|
import '../data/parser/draft_codec.dart';
|
||||||
import '../data/parser/rule_lookup.dart';
|
import '../data/parser/transfer_pair_matcher.dart';
|
||||||
import '../domain/entities/parse_draft.dart';
|
import '../domain/entities/parse_draft.dart';
|
||||||
import '../domain/entities/raw_message.dart';
|
import '../domain/entities/raw_message.dart';
|
||||||
import '../domain/enums.dart';
|
import '../domain/enums.dart';
|
||||||
@@ -26,6 +28,35 @@ Stream<int> inboxCount(Ref ref, String userId) =>
|
|||||||
Stream<List<RawMessage>> parsingLog(Ref ref, String userId) =>
|
Stream<List<RawMessage>> parsingLog(Ref ref, String userId) =>
|
||||||
ref.watch(rawMessagesRepositoryProvider).watchAll(userId);
|
ref.watch(rawMessagesRepositoryProvider).watchAll(userId);
|
||||||
|
|
||||||
|
/// Частые категории подтверждённых транзакций этого приложения — чипы
|
||||||
|
/// быстрого выбора на карточке (источники с selfMerchant: Ozon и т.п.).
|
||||||
|
@riverpod
|
||||||
|
Stream<List<String>> topConfirmCategories(
|
||||||
|
Ref ref,
|
||||||
|
String userId,
|
||||||
|
String packageName,
|
||||||
|
TransactionType type,
|
||||||
|
) =>
|
||||||
|
ref
|
||||||
|
.watch(rawMessagesRepositoryProvider)
|
||||||
|
.watchTopCategoryIds(userId, packageName, type);
|
||||||
|
|
||||||
|
/// Вторая половинка склеенной пары — merged-карточка показывает её
|
||||||
|
/// приложение-источник.
|
||||||
|
@riverpod
|
||||||
|
Future<RawMessage?> pairedRawMessage(Ref ref, String id) =>
|
||||||
|
ref.watch(rawMessagesRepositoryProvider).findById(id);
|
||||||
|
|
||||||
|
/// Отображаемое имя приложения-источника (fallback — packageName).
|
||||||
|
@riverpod
|
||||||
|
Future<String> sourceAppLabel(
|
||||||
|
Ref ref, String userId, String packageName) async {
|
||||||
|
final app = await ref
|
||||||
|
.watch(sourceAppsRepositoryProvider)
|
||||||
|
.findByPackageName(userId, packageName);
|
||||||
|
return app?.displayName ?? packageName;
|
||||||
|
}
|
||||||
|
|
||||||
/// Действия над карточками Inbox (§9.1). Все мутации — явные тапы пользователя.
|
/// Действия над карточками Inbox (§9.1). Все мутации — явные тапы пользователя.
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
class InboxController extends _$InboxController {
|
class InboxController extends _$InboxController {
|
||||||
@@ -61,6 +92,7 @@ class InboxController extends _$InboxController {
|
|||||||
|
|
||||||
await ref.read(parseRulesRepositoryProvider).create(
|
await ref.read(parseRulesRepositoryProvider).create(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
|
packageName: message.packageName,
|
||||||
kind: ParseRuleKind.merchantToCategory,
|
kind: ParseRuleKind.merchantToCategory,
|
||||||
matchMode: matchMode,
|
matchMode: matchMode,
|
||||||
pattern: pattern,
|
pattern: pattern,
|
||||||
@@ -125,60 +157,12 @@ class InboxController extends _$InboxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// «Категории различаются»: помечает мерчанта как mixed (§9.1) — у него
|
/// Привязка сообщения к транзакции, сохранённой через полную форму
|
||||||
/// категория варьируется (Ozon, маркетплейсы), поэтому merchant→category
|
/// (карандаш на карточке): status → applied, сообщение уходит из Inbox.
|
||||||
/// правило для него бессмысленно. Создаёт правило-маркер `mixedMerchant`,
|
Future<void> markApplied(RawMessage message, String transactionId) =>
|
||||||
/// удаляет уже созданное merchant→category правило и наблюдённого кандидата,
|
ref
|
||||||
/// затем перезаписывает кеш `draftJson` без suggestion — карточка тут же
|
.read(rawMessagesRepositoryProvider)
|
||||||
/// переключается на «подтвердить разово».
|
.linkTransaction(message.id, transactionId);
|
||||||
Future<void> markMerchantMixed({
|
|
||||||
required String userId,
|
|
||||||
required RawMessage message,
|
|
||||||
required DraftBundle bundle,
|
|
||||||
required String merchantCanonical,
|
|
||||||
}) async {
|
|
||||||
state = const AsyncLoading();
|
|
||||||
try {
|
|
||||||
final draft = bundle.draft;
|
|
||||||
|
|
||||||
await ref.read(parseRulesRepositoryProvider).create(
|
|
||||||
userId: userId,
|
|
||||||
kind: ParseRuleKind.mixedMerchant,
|
|
||||||
matchMode: MatchMode.contains,
|
|
||||||
pattern: merchantCanonical,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Снести уже существующее merchant→category правило для этого мерчанта,
|
|
||||||
// чтобы оно перестало авто-применять одну категорию.
|
|
||||||
final rules = await ref.read(parseRulesRepositoryProvider).getByUser(userId);
|
|
||||||
final existing = findMerchantRule(rules,
|
|
||||||
body: message.body, merchantRaw: draft.merchantRaw);
|
|
||||||
if (existing != null) {
|
|
||||||
await ref.read(parseRulesRepositoryProvider).deleteById(existing.id);
|
|
||||||
}
|
|
||||||
if (draft.merchantRaw != null) {
|
|
||||||
await ref
|
|
||||||
.read(ruleCandidatesRepositoryProvider)
|
|
||||||
.deleteByRawValue(userId, draft.merchantRaw!);
|
|
||||||
}
|
|
||||||
|
|
||||||
await ref.read(rawMessagesRepositoryProvider).updateAfterParse(
|
|
||||||
id: message.id,
|
|
||||||
status: RawMessageStatus.inbox,
|
|
||||||
draftJson:
|
|
||||||
encodeDraftBundle(draft, null, failedChecks: bundle.failedChecks),
|
|
||||||
confidenceAmount: message.confidenceAmount,
|
|
||||||
confidenceAccount: message.confidenceAccount,
|
|
||||||
confidenceType: message.confidenceType,
|
|
||||||
confidenceMerchant: message.confidenceMerchant,
|
|
||||||
confidenceCategory: message.confidenceCategory,
|
|
||||||
);
|
|
||||||
state = const AsyncData(null);
|
|
||||||
} catch (e, st) {
|
|
||||||
state = AsyncError(e, st);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// «Попробовать снова»: сбросить сообщение в очередь на повторный разбор
|
/// «Попробовать снова»: сбросить сообщение в очередь на повторный разбор
|
||||||
/// (status → pending, попытки → 0). Воркер переобработает его заново.
|
/// (status → pending, попытки → 0). Воркер переобработает его заново.
|
||||||
@@ -186,11 +170,157 @@ class InboxController extends _$InboxController {
|
|||||||
await ref.read(rawMessagesRepositoryProvider).resetForRetry(message.id);
|
await ref.read(rawMessagesRepositoryProvider).resetForRetry(message.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// «Игнорировать»: статус ignored, без правила.
|
/// «Игнорировать»: статус ignored, без правила. У склеенной карточки
|
||||||
|
/// перевода гасит обе половинки.
|
||||||
Future<void> ignore(RawMessage message) async {
|
Future<void> ignore(RawMessage message) async {
|
||||||
await ref
|
final repo = ref.read(rawMessagesRepositoryProvider);
|
||||||
.read(rawMessagesRepositoryProvider)
|
await repo.updateStatus(message.id, RawMessageStatus.ignored);
|
||||||
.updateStatus(message.id, RawMessageStatus.ignored);
|
final pairedId = decodeDraftBundle(message.draftJson)?.pairedRawMessageId;
|
||||||
|
if (pairedId != null) {
|
||||||
|
await repo.updateStatus(pairedId, RawMessageStatus.ignored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// «Подтвердить» склеенную карточку перевода: одна транзакция
|
||||||
|
/// type=transfer, обе половинки → applied с общим transactionId.
|
||||||
|
Future<void> confirmPair({
|
||||||
|
required String userId,
|
||||||
|
required RawMessage message,
|
||||||
|
required ParseDraft draft,
|
||||||
|
required String secondaryId,
|
||||||
|
required String fromAccountId,
|
||||||
|
required String toAccountId,
|
||||||
|
}) async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
try {
|
||||||
|
final tx = await ref
|
||||||
|
.read(transactionsControllerProvider.notifier)
|
||||||
|
.createTransaction(
|
||||||
|
userId: userId,
|
||||||
|
accountId: fromAccountId,
|
||||||
|
categoryId: null,
|
||||||
|
type: TransactionType.transfer,
|
||||||
|
amount: draft.amount,
|
||||||
|
date: draft.dateTime ?? message.receivedAt,
|
||||||
|
merchant: draft.merchantCanonical ?? draft.merchantRaw,
|
||||||
|
transferToAccountId: toAccountId,
|
||||||
|
rawMessageId: message.id,
|
||||||
|
);
|
||||||
|
final repo = ref.read(rawMessagesRepositoryProvider);
|
||||||
|
await repo.linkTransaction(message.id, tx.id);
|
||||||
|
await repo.linkTransaction(secondaryId, tx.id);
|
||||||
|
state = const AsyncData(null);
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncError(e, st);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// «Расклеить» склеенную карточку из Inbox: сигнатура пары в blocklist
|
||||||
|
/// (sweep её больше не склеит), primary восстанавливается из `preMerge`,
|
||||||
|
/// secondary возвращается в Inbox со своим одиночным draftJson.
|
||||||
|
Future<void> unpair({
|
||||||
|
required String userId,
|
||||||
|
required RawMessage message,
|
||||||
|
}) async {
|
||||||
|
final bundle = decodeDraftBundle(message.draftJson);
|
||||||
|
final secondaryId = bundle?.pairedRawMessageId;
|
||||||
|
if (bundle == null || secondaryId == null) return;
|
||||||
|
state = const AsyncLoading();
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(transferPairingBlocklistRepositoryProvider)
|
||||||
|
.add(userId, pairSignature(message.id, secondaryId));
|
||||||
|
|
||||||
|
final pre = bundle.preMerge;
|
||||||
|
final preType = pre?['type'];
|
||||||
|
final restored = bundle.draft.copyWith(
|
||||||
|
type: preType is String
|
||||||
|
? TransactionType.values.byName(preType)
|
||||||
|
: bundle.draft.type,
|
||||||
|
categoryId: pre?['categoryId'] as String?,
|
||||||
|
transferToAccountId: null,
|
||||||
|
);
|
||||||
|
final repo = ref.read(rawMessagesRepositoryProvider);
|
||||||
|
await repo.updateAfterParse(
|
||||||
|
id: message.id,
|
||||||
|
status: RawMessageStatus.inbox,
|
||||||
|
draftJson: encodeDraftBundle(restored, bundle.suggestion,
|
||||||
|
accountTrusted: bundle.accountTrusted),
|
||||||
|
confidenceAmount: message.confidenceAmount,
|
||||||
|
confidenceAccount: message.confidenceAccount,
|
||||||
|
confidenceType: message.confidenceType,
|
||||||
|
confidenceMerchant: message.confidenceMerchant,
|
||||||
|
confidenceCategory: message.confidenceCategory,
|
||||||
|
);
|
||||||
|
await repo.clearPairing(message.id);
|
||||||
|
await repo.clearPairing(secondaryId);
|
||||||
|
await repo.updateStatus(secondaryId, RawMessageStatus.inbox);
|
||||||
|
state = const AsyncData(null);
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncError(e, st);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// «Расклеить перевод» после доклейки (из журнала): откатывает транзакцию
|
||||||
|
/// по `mergeUndo`, отвязывает половинку и возвращает её в Inbox одиночкой.
|
||||||
|
Future<void> unmergeApplied({
|
||||||
|
required String userId,
|
||||||
|
required RawMessage message,
|
||||||
|
}) async {
|
||||||
|
final bundle = decodeDraftBundle(message.draftJson);
|
||||||
|
final undo = bundle?.mergeUndo;
|
||||||
|
if (bundle == null || undo == null) return;
|
||||||
|
state = const AsyncLoading();
|
||||||
|
try {
|
||||||
|
final txId = undo['txId'] as String?;
|
||||||
|
if (txId != null) {
|
||||||
|
final tx =
|
||||||
|
await ref.read(transactionRepositoryProvider).findById(txId);
|
||||||
|
if (tx != null) {
|
||||||
|
final prevType = undo['prevType'];
|
||||||
|
await ref
|
||||||
|
.read(transactionsControllerProvider.notifier)
|
||||||
|
.updateTransaction(tx.copyWith(
|
||||||
|
type: prevType is String
|
||||||
|
? TransactionType.values.byName(prevType)
|
||||||
|
: tx.type,
|
||||||
|
accountId: (undo['prevAccountId'] as String?) ?? tx.accountId,
|
||||||
|
categoryId: undo['prevCategoryId'] as String?,
|
||||||
|
transferToAccountId:
|
||||||
|
undo['prevTransferToAccountId'] as String?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final partnerId = bundle.pairedRawMessageId;
|
||||||
|
if (partnerId != null) {
|
||||||
|
await ref
|
||||||
|
.read(transferPairingBlocklistRepositoryProvider)
|
||||||
|
.add(userId, pairSignature(message.id, partnerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
final repo = ref.read(rawMessagesRepositoryProvider);
|
||||||
|
await repo.unlinkTransaction(message.id);
|
||||||
|
await repo.updateAfterParse(
|
||||||
|
id: message.id,
|
||||||
|
status: RawMessageStatus.inbox,
|
||||||
|
draftJson: encodeDraftBundle(bundle.draft, bundle.suggestion,
|
||||||
|
accountTrusted: bundle.accountTrusted),
|
||||||
|
confidenceAmount: message.confidenceAmount,
|
||||||
|
confidenceAccount: message.confidenceAccount,
|
||||||
|
confidenceType: message.confidenceType,
|
||||||
|
confidenceMerchant: message.confidenceMerchant,
|
||||||
|
confidenceCategory: message.confidenceCategory,
|
||||||
|
);
|
||||||
|
await repo.clearPairing(message.id);
|
||||||
|
if (partnerId != null) await repo.clearPairing(partnerId);
|
||||||
|
state = const AsyncData(null);
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncError(e, st);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// «Игнорировать» + правило-исключение (kind=ignore).
|
/// «Игнорировать» + правило-исключение (kind=ignore).
|
||||||
@@ -204,6 +334,7 @@ class InboxController extends _$InboxController {
|
|||||||
try {
|
try {
|
||||||
await ref.read(parseRulesRepositoryProvider).create(
|
await ref.read(parseRulesRepositoryProvider).create(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
|
packageName: message.packageName,
|
||||||
kind: ParseRuleKind.ignore,
|
kind: ParseRuleKind.ignore,
|
||||||
matchMode: matchMode,
|
matchMode: matchMode,
|
||||||
pattern: pattern,
|
pattern: pattern,
|
||||||
@@ -218,7 +349,9 @@ class InboxController extends _$InboxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// «Учесть все транзакции»: массовое «подтвердить разово».
|
/// «Учесть все транзакции»: массовое «подтвердить разово». Склеенные
|
||||||
|
/// карточки переводов подтверждаются через [confirmPair] и пропускаются,
|
||||||
|
/// пока не выбраны оба счёта.
|
||||||
Future<void> applyAll(String userId) async {
|
Future<void> applyAll(String userId) async {
|
||||||
final messages =
|
final messages =
|
||||||
await ref.read(rawMessagesRepositoryProvider).watchInbox(userId).first;
|
await ref.read(rawMessagesRepositoryProvider).watchInbox(userId).first;
|
||||||
@@ -227,6 +360,23 @@ class InboxController extends _$InboxController {
|
|||||||
for (final msg in messages) {
|
for (final msg in messages) {
|
||||||
final bundle = decodeDraftBundle(msg.draftJson);
|
final bundle = decodeDraftBundle(msg.draftJson);
|
||||||
if (bundle == null) continue;
|
if (bundle == null) continue;
|
||||||
|
|
||||||
|
final secondaryId = bundle.pairedRawMessageId;
|
||||||
|
if (secondaryId != null) {
|
||||||
|
final from = bundle.draft.accountId;
|
||||||
|
final to = bundle.draft.transferToAccountId;
|
||||||
|
if (from == null || to == null) continue;
|
||||||
|
await confirmPair(
|
||||||
|
userId: userId,
|
||||||
|
message: msg,
|
||||||
|
draft: bundle.draft,
|
||||||
|
secondaryId: secondaryId,
|
||||||
|
fromAccountId: from,
|
||||||
|
toAccountId: to,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
final accountId = bundle.draft.accountId ?? defaultAccount?.id;
|
final accountId = bundle.draft.accountId ?? defaultAccount?.id;
|
||||||
if (accountId == null) continue;
|
if (accountId == null) continue;
|
||||||
await confirmOnce(
|
await confirmOnce(
|
||||||
@@ -239,16 +389,6 @@ class InboxController extends _$InboxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// «Скрыть разово»: убрать из Inbox без создания транзакций.
|
|
||||||
Future<void> hideAll(String userId) async {
|
|
||||||
final messages =
|
|
||||||
await ref.read(rawMessagesRepositoryProvider).watchInbox(userId).first;
|
|
||||||
final repo = ref.read(rawMessagesRepositoryProvider);
|
|
||||||
for (final msg in messages) {
|
|
||||||
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Создаёт привязку «карта → счёт», если её ещё нет — чтобы следующее
|
/// Создаёт привязку «карта → счёт», если её ещё нет — чтобы следующее
|
||||||
/// сообщение того же мерчанта прошло gate молча (account score 100).
|
/// сообщение того же мерчанта прошло gate молча (account score 100).
|
||||||
Future<void> _maybeBind(
|
Future<void> _maybeBind(
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import '../data/repositories/parse_rules_repository_impl.dart';
|
|||||||
import '../data/repositories/raw_messages_repository_impl.dart';
|
import '../data/repositories/raw_messages_repository_impl.dart';
|
||||||
import '../data/repositories/rule_candidates_repository_impl.dart';
|
import '../data/repositories/rule_candidates_repository_impl.dart';
|
||||||
import '../data/repositories/source_apps_repository_impl.dart';
|
import '../data/repositories/source_apps_repository_impl.dart';
|
||||||
|
import '../data/repositories/transfer_pairing_blocklist_repository_impl.dart';
|
||||||
import '../domain/repositories/account_bindings_repository.dart';
|
import '../domain/repositories/account_bindings_repository.dart';
|
||||||
import '../domain/repositories/parse_rules_repository.dart';
|
import '../domain/repositories/parse_rules_repository.dart';
|
||||||
import '../domain/repositories/raw_messages_repository.dart';
|
import '../domain/repositories/raw_messages_repository.dart';
|
||||||
import '../domain/repositories/rule_candidates_repository.dart';
|
import '../domain/repositories/rule_candidates_repository.dart';
|
||||||
import '../domain/repositories/source_apps_repository.dart';
|
import '../domain/repositories/source_apps_repository.dart';
|
||||||
|
import '../domain/repositories/transfer_pairing_blocklist_repository.dart';
|
||||||
|
|
||||||
part 'notification_parsing_providers.g.dart';
|
part 'notification_parsing_providers.g.dart';
|
||||||
|
|
||||||
@@ -39,6 +41,12 @@ AccountBindingsRepository accountBindingsRepository(Ref ref) =>
|
|||||||
SourceAppsRepository sourceAppsRepository(Ref ref) =>
|
SourceAppsRepository sourceAppsRepository(Ref ref) =>
|
||||||
SourceAppsRepositoryImpl(ref.watch(appDatabaseProvider).sourceAppsDao);
|
SourceAppsRepositoryImpl(ref.watch(appDatabaseProvider).sourceAppsDao);
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
TransferPairingBlocklistRepository transferPairingBlocklistRepository(
|
||||||
|
Ref ref) =>
|
||||||
|
TransferPairingBlocklistRepositoryImpl(
|
||||||
|
ref.watch(appDatabaseProvider).transferPairingBlocklistDao);
|
||||||
|
|
||||||
/// Множество включённых packageName пользователя. Drift-стрим: эмитит каждое
|
/// Множество включённых packageName пользователя. Drift-стрим: эмитит каждое
|
||||||
/// изменение allowlist, чтобы native-синк в [NotificationIngestWorker] не
|
/// изменение allowlist, чтобы native-синк в [NotificationIngestWorker] не
|
||||||
/// протухал до перезапуска. Подписка на стрим требует прямого слушателя
|
/// протухал до перезапуска. Подписка на стрим требует прямого слушателя
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
import '../../../core/database/converters/enum_converters.dart';
|
||||||
import '../../accounts/application/account_providers.dart';
|
import '../../accounts/application/account_providers.dart';
|
||||||
import '../../categories/application/category_providers.dart';
|
import '../../categories/application/category_providers.dart';
|
||||||
import '../../categories/domain/entities/category.dart';
|
import '../../categories/domain/entities/category.dart';
|
||||||
|
import '../../transactions/application/transaction_providers.dart';
|
||||||
import '../../transactions/application/transactions_controller.dart';
|
import '../../transactions/application/transactions_controller.dart';
|
||||||
import '../data/deepseek/deepseek_client.dart';
|
import '../data/deepseek/deepseek_client.dart';
|
||||||
import '../data/parser/account_resolver.dart';
|
import '../data/parser/account_resolver.dart';
|
||||||
@@ -12,6 +14,7 @@ import '../data/parser/decision_gate.dart';
|
|||||||
import '../data/parser/draft_codec.dart';
|
import '../data/parser/draft_codec.dart';
|
||||||
import '../data/parser/rule_lookup.dart';
|
import '../data/parser/rule_lookup.dart';
|
||||||
import '../data/parser/rule_suggester.dart';
|
import '../data/parser/rule_suggester.dart';
|
||||||
|
import '../data/parser/transfer_pair_matcher.dart';
|
||||||
import '../domain/entities/parse_draft.dart';
|
import '../domain/entities/parse_draft.dart';
|
||||||
import '../domain/entities/raw_message.dart';
|
import '../domain/entities/raw_message.dart';
|
||||||
import '../domain/entities/rule_candidate.dart';
|
import '../domain/entities/rule_candidate.dart';
|
||||||
@@ -48,14 +51,12 @@ class ParsingPipeline {
|
|||||||
if (!settings.enabled) return; // фича выключена — оставляем pending.
|
if (!settings.enabled) return; // фича выключена — оставляем pending.
|
||||||
|
|
||||||
// Allowlist (§A): парсим только включённые приложения-источники. Делаем
|
// Allowlist (§A): парсим только включённые приложения-источники. Делаем
|
||||||
// ДО AI, чтобы не тратить токены на посторонние пакеты. Снапшот берём из
|
// ДО AI, чтобы не тратить токены на посторонние пакеты. Точечный lookup
|
||||||
// репозитория напрямую (`.first` Drift-стрима): свежий на каждый вызов;
|
// вместо снапшота стрима: заодно нужен флаг selfMerchant источника.
|
||||||
// stream-провайдер без прямого слушателя не подписался бы на стрим.
|
final sourceApp = await _ref
|
||||||
final enabled = await _ref
|
|
||||||
.read(sourceAppsRepositoryProvider)
|
.read(sourceAppsRepositoryProvider)
|
||||||
.watchEnabledPackages(userId)
|
.findByPackageName(userId, msg.packageName);
|
||||||
.first;
|
if (sourceApp == null || !sourceApp.enabled) {
|
||||||
if (!enabled.contains(msg.packageName)) {
|
|
||||||
await _ref
|
await _ref
|
||||||
.read(rawMessagesRepositoryProvider)
|
.read(rawMessagesRepositoryProvider)
|
||||||
.updateStatus(msg.id, RawMessageStatus.ignored);
|
.updateStatus(msg.id, RawMessageStatus.ignored);
|
||||||
@@ -66,8 +67,9 @@ class ParsingPipeline {
|
|||||||
// на пуши, которые пользователь явно просил пропускать («Доставлен заказ»).
|
// на пуши, которые пользователь явно просил пропускать («Доставлен заказ»).
|
||||||
// `exact`-правила матчат имя мерчанта, доступное только после AI, — их
|
// `exact`-правила матчат имя мерчанта, доступное только после AI, — их
|
||||||
// проверяет findIgnoreRule в _runPipeline.
|
// проверяет findIgnoreRule в _runPipeline.
|
||||||
final bodyRules =
|
final bodyRules = await _ref
|
||||||
await _ref.read(parseRulesRepositoryProvider).getEnabledByUser(userId);
|
.read(parseRulesRepositoryProvider)
|
||||||
|
.getEnabledForApp(userId, msg.packageName);
|
||||||
if (findBodyIgnoreRule(bodyRules, msg.body) != null) {
|
if (findBodyIgnoreRule(bodyRules, msg.body) != null) {
|
||||||
await _ref
|
await _ref
|
||||||
.read(rawMessagesRepositoryProvider)
|
.read(rawMessagesRepositoryProvider)
|
||||||
@@ -76,7 +78,8 @@ class ParsingPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Извлечение через AI (regex-этап удалён). Без AI/сети — Inbox/ignored.
|
// Извлечение через AI (regex-этап удалён). Без AI/сети — Inbox/ignored.
|
||||||
await _extractViaAi(userId, msg, settings);
|
await _extractViaAi(userId, msg, settings,
|
||||||
|
selfMerchant: sourceApp.selfMerchant);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Извлечение через AI: спам без цифр → ignored; зовём AI (если разрешён),
|
/// Извлечение через AI: спам без цифр → ignored; зовём AI (если разрешён),
|
||||||
@@ -84,8 +87,9 @@ class ParsingPipeline {
|
|||||||
Future<void> _extractViaAi(
|
Future<void> _extractViaAi(
|
||||||
String userId,
|
String userId,
|
||||||
RawMessage msg,
|
RawMessage msg,
|
||||||
ParsingSettings settings,
|
ParsingSettings settings, {
|
||||||
) async {
|
required bool selfMerchant,
|
||||||
|
}) async {
|
||||||
final repo = _ref.read(rawMessagesRepositoryProvider);
|
final repo = _ref.read(rawMessagesRepositoryProvider);
|
||||||
final settingsCtrl = _ref.read(parsingSettingsControllerProvider.notifier);
|
final settingsCtrl = _ref.read(parsingSettingsControllerProvider.notifier);
|
||||||
|
|
||||||
@@ -161,7 +165,7 @@ class ParsingPipeline {
|
|||||||
);
|
);
|
||||||
case AiParseStatus.draft:
|
case AiParseStatus.draft:
|
||||||
await _runPipeline(userId, msg, outcome.draft!, settings,
|
await _runPipeline(userId, msg, outcome.draft!, settings,
|
||||||
categories: categories);
|
categories: categories, selfMerchant: selfMerchant);
|
||||||
}
|
}
|
||||||
} on DeepSeekNetworkException {
|
} on DeepSeekNetworkException {
|
||||||
await repo.updateAfterParse(
|
await repo.updateAfterParse(
|
||||||
@@ -189,18 +193,30 @@ class ParsingPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Общий хвост pipeline (§5, шаги 3–8) для AI-draft.
|
/// Общий хвост pipeline (§5, шаги 3–8) для AI-draft.
|
||||||
|
///
|
||||||
|
/// [selfMerchant] — источник помечен «мерчант — само приложение» (Ozon):
|
||||||
|
/// AI-подсказка категории глушится, merchant→category правило не
|
||||||
|
/// предлагается — категорию пользователь выбирает вручную на карточке.
|
||||||
Future<void> _runPipeline(
|
Future<void> _runPipeline(
|
||||||
String userId,
|
String userId,
|
||||||
RawMessage msg,
|
RawMessage msg,
|
||||||
ParseDraft draft0,
|
ParseDraft draft0,
|
||||||
ParsingSettings settings, {
|
ParsingSettings settings, {
|
||||||
List<Category>? categories,
|
List<Category>? categories,
|
||||||
|
bool selfMerchant = false,
|
||||||
}) async {
|
}) async {
|
||||||
final repo = _ref.read(rawMessagesRepositoryProvider);
|
final repo = _ref.read(rawMessagesRepositoryProvider);
|
||||||
|
|
||||||
|
if (selfMerchant) {
|
||||||
|
// «Нет AI-префилла»: карточка читает подсказку из draftJson, поэтому
|
||||||
|
// достаточно снять её здесь — виджету флаг знать не нужно.
|
||||||
|
draft0 = draft0.copyWith(categorySuggestion: null);
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Правила грузим раньше — нужны резолверу (senderToAccount).
|
// 4. Правила грузим раньше — нужны резолверу (senderToAccount).
|
||||||
final rules =
|
final rules = await _ref
|
||||||
await _ref.read(parseRulesRepositoryProvider).getEnabledByUser(userId);
|
.read(parseRulesRepositoryProvider)
|
||||||
|
.getEnabledForApp(userId, msg.packageName);
|
||||||
if (findIgnoreRule(rules, body: msg.body, merchantRaw: draft0.merchantRaw) !=
|
if (findIgnoreRule(rules, body: msg.body, merchantRaw: draft0.merchantRaw) !=
|
||||||
null) {
|
null) {
|
||||||
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
|
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
|
||||||
@@ -249,16 +265,12 @@ class ParsingPipeline {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Предложение правила для Inbox (только для незнакомого мерчанта, если
|
// Предложение правила для Inbox — только для незнакомого мерчанта и не
|
||||||
// пользователь не пометил его как «категории различаются»). Без мерчанта
|
// для selfMerchant-источников (мерчант там — само приложение, правило
|
||||||
// или для mixed-мерчанта suggestion остаётся null → карточка покажет
|
// бессмысленно). Без suggestion карточка покажет «Подтвердить» вместо
|
||||||
// «подтвердить разово» вместо кнопки «Создать правило».
|
// кнопки «Создать правило».
|
||||||
RuleSuggestion? suggestion;
|
RuleSuggestion? suggestion;
|
||||||
if (rule == null &&
|
if (!selfMerchant && rule == null && draft.merchantRaw != null) {
|
||||||
draft.merchantRaw != null &&
|
|
||||||
findMixedMerchantRule(rules,
|
|
||||||
body: msg.body, merchantRaw: draft.merchantRaw) ==
|
|
||||||
null) {
|
|
||||||
// AI-подсказка категории: матчим имя на существующую категорию (§7).
|
// AI-подсказка категории: матчим имя на существующую категорию (§7).
|
||||||
String? aiCategoryId;
|
String? aiCategoryId;
|
||||||
String? aiCategoryName;
|
String? aiCategoryName;
|
||||||
@@ -300,6 +312,25 @@ class ParsingPipeline {
|
|||||||
categoryCandidate: candidate,
|
categoryCandidate: candidate,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 7. Transfer pairing: transfer-половинка не идёт через gate — паркуется
|
||||||
|
// в waitingPair; склейку/доклейку/релиз по таймауту делает [sweepPairs]
|
||||||
|
// (сериализован воркером). accountTrusted снапшотим в bundle: на момент
|
||||||
|
// sweep resolution уже недоступен, а доклейка требует trusted-счёт.
|
||||||
|
if (settings.transferPairingEnabled && isTransferHalf(draft)) {
|
||||||
|
await repo.holdForPairing(
|
||||||
|
id: msg.id,
|
||||||
|
draftJson: encodeDraftBundle(draft, suggestion,
|
||||||
|
accountTrusted: resolution.trusted),
|
||||||
|
deadline: DateTime.now().add(kPairingWindow),
|
||||||
|
confidenceAmount: scores.amount,
|
||||||
|
confidenceAccount: scores.account,
|
||||||
|
confidenceType: scores.type,
|
||||||
|
confidenceMerchant: scores.merchant,
|
||||||
|
confidenceCategory: scores.category,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 8. Gate: чек-лист именованных проверок (decision_gate.dart).
|
// 8. Gate: чек-лист именованных проверок (decision_gate.dart).
|
||||||
final gate = decide(
|
final gate = decide(
|
||||||
autoApplyEnabled: settings.autoApplyEnabled,
|
autoApplyEnabled: settings.autoApplyEnabled,
|
||||||
@@ -379,4 +410,237 @@ class ParsingPipeline {
|
|||||||
.incrementMatchCount(bindingId);
|
.incrementMatchCount(bindingId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Transfer pairing ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Один проход склейки переводов. НЕ вызывать напрямую параллельно:
|
||||||
|
/// сериализацию обеспечивает ParsingWorker — все решения о склейке
|
||||||
|
/// принимает одна последовательная точка, гонок нет по построению.
|
||||||
|
///
|
||||||
|
/// Шаги: (1) пары среди waitingPair; (2) для оставшихся — контрпартнёр в
|
||||||
|
/// Inbox (склейка) или среди applied (доклейка в существующую транзакцию);
|
||||||
|
/// (3) релиз просроченных в Inbox одиночными карточками.
|
||||||
|
Future<void> sweepPairs(String userId) async {
|
||||||
|
final repo = _ref.read(rawMessagesRepositoryProvider);
|
||||||
|
final waiting = await repo.watchWaitingPair(userId).first;
|
||||||
|
if (waiting.isEmpty) return;
|
||||||
|
|
||||||
|
// Декодим bundle каждой ждущей половинки; повреждённые (без draft/kind)
|
||||||
|
// остаются в списке waiting и уходят в Inbox по дедлайну на шаге 3.
|
||||||
|
final halves = <_PairHalf>[];
|
||||||
|
for (final m in waiting) {
|
||||||
|
final b = decodeDraftBundle(m.draftJson);
|
||||||
|
if (b != null && isTransferHalf(b.draft)) halves.add((msg: m, bundle: b));
|
||||||
|
}
|
||||||
|
|
||||||
|
final handled = <String>{};
|
||||||
|
|
||||||
|
// 1. Пары среди самих waitingPair (сюда же попадает бэклог после
|
||||||
|
// рестарта и обе половинки, распарсенные параллельно).
|
||||||
|
for (var i = 0; i < halves.length; i++) {
|
||||||
|
final a = halves[i];
|
||||||
|
if (handled.contains(a.msg.id)) continue;
|
||||||
|
final candidates = <(_PairHalf, DateTime)>[];
|
||||||
|
for (var j = i + 1; j < halves.length; j++) {
|
||||||
|
final b = halves[j];
|
||||||
|
if (handled.contains(b.msg.id)) continue;
|
||||||
|
if (!_isCandidatePair(a, b)) continue;
|
||||||
|
if (await _isBlocked(userId, a.msg.id, b.msg.id)) continue;
|
||||||
|
candidates.add((b, b.msg.receivedAt));
|
||||||
|
}
|
||||||
|
if (candidates.isEmpty) continue;
|
||||||
|
final partner = pickNearest(candidates, a.msg.receivedAt);
|
||||||
|
await _mergePair(a, partner);
|
||||||
|
handled..add(a.msg.id)..add(partner.msg.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Контрпартнёр среди Inbox (склейка) / applied (доклейка).
|
||||||
|
for (final h in halves) {
|
||||||
|
if (handled.contains(h.msg.id)) continue;
|
||||||
|
final recent = await repo.findRecentByStatuses(
|
||||||
|
userId,
|
||||||
|
h.msg.receivedAt.subtract(kPairingWindow),
|
||||||
|
h.msg.receivedAt.add(kPairingWindow),
|
||||||
|
const [RawMessageStatus.inbox, RawMessageStatus.applied],
|
||||||
|
);
|
||||||
|
|
||||||
|
final inboxCands = <(_PairHalf, DateTime)>[];
|
||||||
|
final appliedCands = <(_PairHalf, DateTime)>[];
|
||||||
|
for (final m in recent) {
|
||||||
|
if (m.id == h.msg.id || m.pairedWithId != null) continue;
|
||||||
|
final b = decodeDraftBundle(m.draftJson);
|
||||||
|
if (b == null || !isTransferHalf(b.draft)) continue;
|
||||||
|
// Уже склеенная merged-карточка второй раз не участвует.
|
||||||
|
if (b.pairedRawMessageId != null) continue;
|
||||||
|
final cand = (msg: m, bundle: b);
|
||||||
|
if (!_isCandidatePair(h, cand)) continue;
|
||||||
|
if (await _isBlocked(userId, h.msg.id, m.id)) continue;
|
||||||
|
if (m.status == RawMessageStatus.inbox) {
|
||||||
|
inboxCands.add((cand, m.receivedAt));
|
||||||
|
} else if (m.transactionId != null) {
|
||||||
|
appliedCands.add((cand, m.receivedAt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inboxCands.isNotEmpty) {
|
||||||
|
// Контрпартнёр уже в Inbox (релизнут по таймауту раньше / парсился
|
||||||
|
// при выключенном пейринге) — обычная склейка, его одиночная
|
||||||
|
// карточка заменяется merged-карточкой.
|
||||||
|
await _mergePair(h, pickNearest(inboxCands, h.msg.receivedAt));
|
||||||
|
handled.add(h.msg.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appliedCands.isNotEmpty) {
|
||||||
|
// Доклейка — только при разрешённом и trusted счёте новой половинки;
|
||||||
|
// иначе обычный одиночный путь в Inbox без ожидания дедлайна
|
||||||
|
// (пара уже найдена, но склеивать её автоматически нельзя).
|
||||||
|
if (h.bundle.draft.accountId != null && h.bundle.accountTrusted) {
|
||||||
|
final ok =
|
||||||
|
await _lateMerge(h, pickNearest(appliedCands, h.msg.receivedAt));
|
||||||
|
if (ok) {
|
||||||
|
handled.add(h.msg.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await repo.updateStatus(h.msg.id, RawMessageStatus.inbox);
|
||||||
|
handled.add(h.msg.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Релиз просроченных в Inbox. Только updateStatus: bundle и
|
||||||
|
// confidence уже сохранены при holdForPairing, затирать их нельзя.
|
||||||
|
final now = DateTime.now();
|
||||||
|
for (final m in waiting) {
|
||||||
|
if (handled.contains(m.id)) continue;
|
||||||
|
final deadline = m.pairDeadline;
|
||||||
|
if (deadline == null || !deadline.isAfter(now)) {
|
||||||
|
await repo.updateStatus(m.id, RawMessageStatus.inbox);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Немедленный релиз всех ждущих половинок в Inbox — при выключении
|
||||||
|
/// тумблера склейки (не держим сообщения скрытыми до дедлайна).
|
||||||
|
Future<void> releaseAllWaiting(String userId) async {
|
||||||
|
final repo = _ref.read(rawMessagesRepositoryProvider);
|
||||||
|
final waiting = await repo.watchWaitingPair(userId).first;
|
||||||
|
for (final m in waiting) {
|
||||||
|
await repo.updateStatus(m.id, RawMessageStatus.inbox);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isCandidatePair(_PairHalf a, _PairHalf b) => isPair(
|
||||||
|
a: a.bundle.draft,
|
||||||
|
aReceivedAt: a.msg.receivedAt,
|
||||||
|
b: b.bundle.draft,
|
||||||
|
bReceivedAt: b.msg.receivedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<bool> _isBlocked(String userId, String idA, String idB) =>
|
||||||
|
_ref
|
||||||
|
.read(transferPairingBlocklistRepositoryProvider)
|
||||||
|
.contains(userId, pairSignature(idA, idB));
|
||||||
|
|
||||||
|
/// Склейка двух непримененных половинок: primary = transferOut (его счёт —
|
||||||
|
/// источник) уходит в Inbox merged-карточкой, secondary скрывается как
|
||||||
|
/// `paired`. Свой draftJson secondary сохраняет — он нужен для расклейки.
|
||||||
|
Future<void> _mergePair(_PairHalf a, _PairHalf b) async {
|
||||||
|
final out = a.bundle.draft.kind == TxKind.transferOut ? a : b;
|
||||||
|
final inn = out == a ? b : a;
|
||||||
|
|
||||||
|
final mergedDraft = out.bundle.draft.copyWith(
|
||||||
|
type: TransactionType.transfer,
|
||||||
|
transferToAccountId: inn.bundle.draft.accountId,
|
||||||
|
categoryId: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
final repo = _ref.read(rawMessagesRepositoryProvider);
|
||||||
|
await repo.updateAfterParse(
|
||||||
|
id: out.msg.id,
|
||||||
|
status: RawMessageStatus.inbox,
|
||||||
|
draftJson: encodeDraftBundle(
|
||||||
|
mergedDraft,
|
||||||
|
// Suggestion сохраняем: merged-карточка его не показывает, но после
|
||||||
|
// расклейки primary снова становится обычной карточкой.
|
||||||
|
out.bundle.suggestion,
|
||||||
|
accountTrusted: out.bundle.accountTrusted,
|
||||||
|
pairedRawMessageId: inn.msg.id,
|
||||||
|
preMerge: {
|
||||||
|
'type': out.bundle.draft.type.name,
|
||||||
|
'categoryId': out.bundle.draft.categoryId,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
confidenceAmount: out.msg.confidenceAmount,
|
||||||
|
confidenceAccount: out.msg.confidenceAccount,
|
||||||
|
confidenceType: out.msg.confidenceType,
|
||||||
|
confidenceMerchant: out.msg.confidenceMerchant,
|
||||||
|
confidenceCategory: out.msg.confidenceCategory,
|
||||||
|
);
|
||||||
|
await repo.setPairing(out.msg.id, inn.msg.id);
|
||||||
|
await repo.setPairing(inn.msg.id, out.msg.id,
|
||||||
|
status: RawMessageStatus.paired);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Доклейка: контрпартнёр уже применён обычной транзакцией — конвертируем
|
||||||
|
/// её в transfer, новая половинка привязывается к той же транзакции.
|
||||||
|
/// Снапшот прежних полей tx уходит в `mergeUndo` для отката из журнала.
|
||||||
|
Future<bool> _lateMerge(_PairHalf h, _PairHalf applied) async {
|
||||||
|
final txId = applied.msg.transactionId;
|
||||||
|
if (txId == null) return false;
|
||||||
|
final tx = await _ref.read(transactionRepositoryProvider).findById(txId);
|
||||||
|
if (tx == null) return false;
|
||||||
|
|
||||||
|
final newAccountId = h.bundle.draft.accountId!;
|
||||||
|
final updated = applied.bundle.draft.kind == TxKind.transferOut
|
||||||
|
// Существующая tx — списание: её счёт остаётся источником.
|
||||||
|
? tx.copyWith(
|
||||||
|
type: TransactionType.transfer,
|
||||||
|
transferToAccountId: newAccountId,
|
||||||
|
categoryId: null,
|
||||||
|
)
|
||||||
|
// Существующая tx — зачисление: её счёт становится получателем,
|
||||||
|
// источником — счёт новой out-половинки.
|
||||||
|
: tx.copyWith(
|
||||||
|
type: TransactionType.transfer,
|
||||||
|
accountId: newAccountId,
|
||||||
|
transferToAccountId: tx.accountId,
|
||||||
|
categoryId: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _ref
|
||||||
|
.read(transactionsControllerProvider.notifier)
|
||||||
|
.updateTransaction(updated);
|
||||||
|
|
||||||
|
final repo = _ref.read(rawMessagesRepositoryProvider);
|
||||||
|
await repo.updateAfterParse(
|
||||||
|
id: h.msg.id,
|
||||||
|
status: RawMessageStatus.applied,
|
||||||
|
draftJson: encodeDraftBundle(
|
||||||
|
h.bundle.draft,
|
||||||
|
h.bundle.suggestion,
|
||||||
|
accountTrusted: h.bundle.accountTrusted,
|
||||||
|
pairedRawMessageId: applied.msg.id,
|
||||||
|
mergeUndo: {
|
||||||
|
'txId': tx.id,
|
||||||
|
'prevType': tx.type.name,
|
||||||
|
'prevAccountId': tx.accountId,
|
||||||
|
'prevCategoryId': tx.categoryId,
|
||||||
|
'prevTransferToAccountId': tx.transferToAccountId,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
confidenceAmount: h.msg.confidenceAmount,
|
||||||
|
confidenceAccount: h.msg.confidenceAccount,
|
||||||
|
confidenceType: h.msg.confidenceType,
|
||||||
|
confidenceMerchant: h.msg.confidenceMerchant,
|
||||||
|
confidenceCategory: h.msg.confidenceCategory,
|
||||||
|
);
|
||||||
|
await repo.linkTransaction(h.msg.id, tx.id);
|
||||||
|
await repo.setPairing(h.msg.id, applied.msg.id);
|
||||||
|
await repo.setPairing(applied.msg.id, h.msg.id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Половинка пары: сообщение + декодированный bundle.
|
||||||
|
typedef _PairHalf = ({RawMessage msg, DraftBundle bundle});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class ParsingSettings {
|
|||||||
const ParsingSettings({
|
const ParsingSettings({
|
||||||
required this.enabled,
|
required this.enabled,
|
||||||
required this.autoApplyEnabled,
|
required this.autoApplyEnabled,
|
||||||
|
required this.transferPairingEnabled,
|
||||||
required this.aiConsentGiven,
|
required this.aiConsentGiven,
|
||||||
required this.aiModel,
|
required this.aiModel,
|
||||||
required this.aiDailyTokenLimit,
|
required this.aiDailyTokenLimit,
|
||||||
@@ -25,6 +26,11 @@ class ParsingSettings {
|
|||||||
/// gate-проверок (см. `AutoApplyCheck` в decision_gate.dart).
|
/// gate-проверок (см. `AutoApplyCheck` в decision_gate.dart).
|
||||||
final bool autoApplyEnabled;
|
final bool autoApplyEnabled;
|
||||||
|
|
||||||
|
/// Склейка переводов между счетами: transfer-половинки ждут пару
|
||||||
|
/// (`waitingPair`) и объединяются в одну transfer-транзакцию. При
|
||||||
|
/// выключении идут обычным одиночным путём.
|
||||||
|
final bool transferPairingEnabled;
|
||||||
|
|
||||||
/// Дано ли согласие на отправку текста уведомлений в AI (§7/§12.6).
|
/// Дано ли согласие на отправку текста уведомлений в AI (§7/§12.6).
|
||||||
/// Без него AI не вызывается — работает только regex.
|
/// Без него AI не вызывается — работает только regex.
|
||||||
final bool aiConsentGiven;
|
final bool aiConsentGiven;
|
||||||
@@ -50,6 +56,7 @@ class ParsingSettings {
|
|||||||
ParsingSettings copyWith({
|
ParsingSettings copyWith({
|
||||||
bool? enabled,
|
bool? enabled,
|
||||||
bool? autoApplyEnabled,
|
bool? autoApplyEnabled,
|
||||||
|
bool? transferPairingEnabled,
|
||||||
bool? aiConsentGiven,
|
bool? aiConsentGiven,
|
||||||
String? aiModel,
|
String? aiModel,
|
||||||
int? aiDailyTokenLimit,
|
int? aiDailyTokenLimit,
|
||||||
@@ -60,6 +67,8 @@ class ParsingSettings {
|
|||||||
ParsingSettings(
|
ParsingSettings(
|
||||||
enabled: enabled ?? this.enabled,
|
enabled: enabled ?? this.enabled,
|
||||||
autoApplyEnabled: autoApplyEnabled ?? this.autoApplyEnabled,
|
autoApplyEnabled: autoApplyEnabled ?? this.autoApplyEnabled,
|
||||||
|
transferPairingEnabled:
|
||||||
|
transferPairingEnabled ?? this.transferPairingEnabled,
|
||||||
aiConsentGiven: aiConsentGiven ?? this.aiConsentGiven,
|
aiConsentGiven: aiConsentGiven ?? this.aiConsentGiven,
|
||||||
aiModel: aiModel ?? this.aiModel,
|
aiModel: aiModel ?? this.aiModel,
|
||||||
aiDailyTokenLimit:
|
aiDailyTokenLimit:
|
||||||
@@ -74,6 +83,7 @@ const _kEnabled = 'parsing_enabled';
|
|||||||
// Старый ключ 'parsing_auto_apply_strictness' (75/85/95) больше не читается:
|
// Старый ключ 'parsing_auto_apply_strictness' (75/85/95) больше не читается:
|
||||||
// числовой гейт заменён чек-листом, фича до замены не срабатывала ни разу.
|
// числовой гейт заменён чек-листом, фича до замены не срабатывала ни разу.
|
||||||
const _kAutoApply = 'parsing_auto_apply_enabled';
|
const _kAutoApply = 'parsing_auto_apply_enabled';
|
||||||
|
const _kTransferPairing = 'parsing_transfer_pairing_enabled';
|
||||||
const _kAiConsent = 'ai_consent';
|
const _kAiConsent = 'ai_consent';
|
||||||
const _kAiModel = 'ai_model';
|
const _kAiModel = 'ai_model';
|
||||||
const _kAiDailyLimit = 'ai_daily_token_limit';
|
const _kAiDailyLimit = 'ai_daily_token_limit';
|
||||||
@@ -84,6 +94,7 @@ const kDefaultAiModel = 'deepseek-chat';
|
|||||||
const _defaultSettings = ParsingSettings(
|
const _defaultSettings = ParsingSettings(
|
||||||
enabled: true,
|
enabled: true,
|
||||||
autoApplyEnabled: true,
|
autoApplyEnabled: true,
|
||||||
|
transferPairingEnabled: true,
|
||||||
aiConsentGiven: false,
|
aiConsentGiven: false,
|
||||||
aiModel: kDefaultAiModel,
|
aiModel: kDefaultAiModel,
|
||||||
aiDailyTokenLimit: null,
|
aiDailyTokenLimit: null,
|
||||||
@@ -107,6 +118,7 @@ class ParsingSettingsController extends _$ParsingSettingsController {
|
|||||||
final dao = ref.watch(appDatabaseProvider).settingsDao;
|
final dao = ref.watch(appDatabaseProvider).settingsDao;
|
||||||
final enabledStr = await dao.getPreference(_kEnabled);
|
final enabledStr = await dao.getPreference(_kEnabled);
|
||||||
final autoApplyStr = await dao.getPreference(_kAutoApply);
|
final autoApplyStr = await dao.getPreference(_kAutoApply);
|
||||||
|
final pairingStr = await dao.getPreference(_kTransferPairing);
|
||||||
final consentStr = await dao.getPreference(_kAiConsent);
|
final consentStr = await dao.getPreference(_kAiConsent);
|
||||||
final modelStr = await dao.getPreference(_kAiModel);
|
final modelStr = await dao.getPreference(_kAiModel);
|
||||||
final limitStr = await dao.getPreference(_kAiDailyLimit);
|
final limitStr = await dao.getPreference(_kAiDailyLimit);
|
||||||
@@ -119,6 +131,9 @@ class ParsingSettingsController extends _$ParsingSettingsController {
|
|||||||
autoApplyEnabled: autoApplyStr == null
|
autoApplyEnabled: autoApplyStr == null
|
||||||
? _defaultSettings.autoApplyEnabled
|
? _defaultSettings.autoApplyEnabled
|
||||||
: autoApplyStr == 'true',
|
: autoApplyStr == 'true',
|
||||||
|
transferPairingEnabled: pairingStr == null
|
||||||
|
? _defaultSettings.transferPairingEnabled
|
||||||
|
: pairingStr == 'true',
|
||||||
aiConsentGiven: consentStr == 'true',
|
aiConsentGiven: consentStr == 'true',
|
||||||
aiModel: (modelStr != null && modelStr.isNotEmpty)
|
aiModel: (modelStr != null && modelStr.isNotEmpty)
|
||||||
? modelStr
|
? modelStr
|
||||||
@@ -144,6 +159,12 @@ class ParsingSettingsController extends _$ParsingSettingsController {
|
|||||||
state = AsyncData(current.copyWith(autoApplyEnabled: value));
|
state = AsyncData(current.copyWith(autoApplyEnabled: value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setTransferPairingEnabled(bool value) async {
|
||||||
|
await _set(_kTransferPairing, '$value');
|
||||||
|
final current = state.value ?? _defaultSettings;
|
||||||
|
state = AsyncData(current.copyWith(transferPairingEnabled: value));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setDiagnosticMode(bool value) async {
|
Future<void> setDiagnosticMode(bool value) async {
|
||||||
await _set(_kDiagnosticMode, '$value');
|
await _set(_kDiagnosticMode, '$value');
|
||||||
final current = state.value ?? _defaultSettings;
|
final current = state.value ?? _defaultSettings;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
import '../domain/entities/raw_message.dart';
|
import '../domain/entities/raw_message.dart';
|
||||||
@@ -9,11 +11,6 @@ import 'parsing_settings_controller.dart';
|
|||||||
|
|
||||||
part 'parsing_worker.g.dart';
|
part 'parsing_worker.g.dart';
|
||||||
|
|
||||||
/// Поток необработанных сообщений — вход воркера.
|
|
||||||
@riverpod
|
|
||||||
Stream<List<RawMessage>> pendingMessages(Ref ref, String userId) =>
|
|
||||||
ref.watch(rawMessagesRepositoryProvider).watchPending(userId);
|
|
||||||
|
|
||||||
/// ParsingWorker (§5): foreground-драйвер pipeline. Слушает
|
/// ParsingWorker (§5): foreground-драйвер pipeline. Слушает
|
||||||
/// `raw_messages.pending` и прогоняет каждое сообщение через [ParsingPipeline].
|
/// `raw_messages.pending` и прогоняет каждое сообщение через [ParsingPipeline].
|
||||||
/// Отвечает только за «когда запускать» (триггер/дедуп/ретраи); сама обработка
|
/// Отвечает только за «когда запускать» (триггер/дедуп/ретраи); сама обработка
|
||||||
@@ -21,7 +18,14 @@ Stream<List<RawMessage>> pendingMessages(Ref ref, String userId) =>
|
|||||||
/// поверх того же кода без дублирования.
|
/// поверх того же кода без дублирования.
|
||||||
///
|
///
|
||||||
/// Идемпотентен: при возврате сообщения в `pending` перепарсивается.
|
/// Идемпотентен: при возврате сообщения в `pending` перепарсивается.
|
||||||
/// Провайдер `keepAlive` — активируется чтением из HomeScreen.
|
/// Провайдер `keepAlive` — активируется `ref.watch` из AppScaffold.
|
||||||
|
///
|
||||||
|
/// Оба входа (pending и waitingPair) — прямые подписки на Drift-стримы
|
||||||
|
/// репозитория, БЕЗ промежуточных autoDispose stream-провайдеров: у тех нет
|
||||||
|
/// UI-слушателей, а внутренний `ref.listen` приостановленного воркера (bare
|
||||||
|
/// `ProviderContainer` в тестах) их не активирует — pause-семантика riverpod 3.
|
||||||
|
/// Прямая подписка не зависит от activity-семантики вовсе; в тестах достаточно
|
||||||
|
/// `container.read(parsingWorkerProvider(userId))`.
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
class ParsingWorker extends _$ParsingWorker {
|
class ParsingWorker extends _$ParsingWorker {
|
||||||
final Set<String> _inFlight = {};
|
final Set<String> _inFlight = {};
|
||||||
@@ -30,25 +34,31 @@ class ParsingWorker extends _$ParsingWorker {
|
|||||||
// (иначе при перезапуске приложения они не ретраятся — §7).
|
// (иначе при перезапуске приложения они не ретраятся — §7).
|
||||||
bool _wasOnline = false;
|
bool _wasOnline = false;
|
||||||
|
|
||||||
|
// Сериализация sweep склейки переводов: одновременно работает максимум
|
||||||
|
// один sweepPairs; запрос, пришедший во время работы, выполняется следом.
|
||||||
|
bool _sweeping = false;
|
||||||
|
bool _sweepRequested = false;
|
||||||
|
Timer? _pairTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void build(String userId) {
|
void build(String userId) {
|
||||||
final sub = ref.listen(
|
// Первая эмиссия Drift-стрима — текущий снапшот: бэклог `pending`,
|
||||||
pendingMessagesProvider(userId),
|
// накопившийся до старта приложения, дренится сразу.
|
||||||
(_, next) {
|
final pendingSub = ref
|
||||||
final list = next.value;
|
.read(rawMessagesRepositoryProvider)
|
||||||
if (list == null || list.isEmpty) return;
|
.watchPending(userId)
|
||||||
// Фича выключена: pipeline всё равно no-op'нул бы каждое сообщение —
|
.listen((list) {
|
||||||
// не прогоняем растущий pending заново на каждую новую вставку.
|
if (list.isEmpty) return;
|
||||||
// Обратное включение тумблера запускает явный drain (см. enabledSub).
|
// Фича выключена: pipeline всё равно no-op'нул бы каждое сообщение —
|
||||||
if (ref.read(parsingSettingsControllerProvider).value?.enabled ==
|
// не прогоняем растущий pending заново на каждую новую вставку.
|
||||||
false) {
|
// Обратное включение тумблера запускает явный drain (см. enabledSub).
|
||||||
return;
|
if (ref.read(parsingSettingsControllerProvider).value?.enabled ==
|
||||||
}
|
false) {
|
||||||
_drain(userId, list);
|
return;
|
||||||
},
|
}
|
||||||
fireImmediately: true,
|
_drain(userId, list);
|
||||||
);
|
});
|
||||||
ref.onDispose(sub.close);
|
ref.onDispose(pendingSub.cancel);
|
||||||
|
|
||||||
// Размораживаем очередь при обратном включении фичи: raw_messages не
|
// Размораживаем очередь при обратном включении фичи: raw_messages не
|
||||||
// менялись, поэтому pending-стрим сам не переэмитит застрявшие сообщения.
|
// менялись, поэтому pending-стрим сам не переэмитит застрявшие сообщения.
|
||||||
@@ -75,6 +85,72 @@ class ParsingWorker extends _$ParsingWorker {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
ref.onDispose(onlineSub.close);
|
ref.onDispose(onlineSub.close);
|
||||||
|
|
||||||
|
// Transfer pairing: каждая эмиссия waitingPair (новая половинка, склейка,
|
||||||
|
// релиз) запускает sweep и перевзводит таймер ближайшего дедлайна.
|
||||||
|
// Первая эмиссия покрывает старт приложения: дедлайны в БД, рестарт
|
||||||
|
// переживаем. НЕ гейтится флагом enabled — релиз в Inbox не требует AI.
|
||||||
|
final waitingSub = ref
|
||||||
|
.read(rawMessagesRepositoryProvider)
|
||||||
|
.watchWaitingPair(userId)
|
||||||
|
.listen((list) {
|
||||||
|
_armPairTimer(userId, list);
|
||||||
|
if (list.isNotEmpty) _requestSweep(userId);
|
||||||
|
});
|
||||||
|
ref.onDispose(waitingSub.cancel);
|
||||||
|
ref.onDispose(() => _pairTimer?.cancel());
|
||||||
|
|
||||||
|
// Выключение тумблера склейки — немедленный релиз ждущих половинок в
|
||||||
|
// Inbox (не держим сообщения скрытыми до дедлайна).
|
||||||
|
final pairingSub = ref.listen(
|
||||||
|
parsingSettingsControllerProvider,
|
||||||
|
(prev, next) {
|
||||||
|
if (next.value?.transferPairingEnabled == false &&
|
||||||
|
prev?.value?.transferPairingEnabled == true) {
|
||||||
|
ref.read(parsingPipelineProvider).releaseAllWaiting(userId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ref.onDispose(pairingSub.close);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Перевзводит таймер на ближайший `pairDeadline` (sweep по его истечении
|
||||||
|
/// релизнет просроченные половинки в Inbox).
|
||||||
|
void _armPairTimer(String userId, List<RawMessage> waiting) {
|
||||||
|
_pairTimer?.cancel();
|
||||||
|
_pairTimer = null;
|
||||||
|
DateTime? nearest;
|
||||||
|
for (final m in waiting) {
|
||||||
|
final d = m.pairDeadline;
|
||||||
|
if (d != null && (nearest == null || d.isBefore(nearest))) nearest = d;
|
||||||
|
}
|
||||||
|
if (nearest == null) return;
|
||||||
|
var delay = nearest.difference(DateTime.now());
|
||||||
|
if (delay.isNegative) delay = Duration.zero;
|
||||||
|
// +1s, чтобы при срабатывании дедлайн гарантированно был в прошлом.
|
||||||
|
_pairTimer = Timer(delay + const Duration(seconds: 1), () {
|
||||||
|
_requestSweep(userId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Сериализованный запуск [ParsingPipeline.sweepPairs]: параллельные
|
||||||
|
/// запросы схлопываются в «выполнить ещё раз после текущего прохода».
|
||||||
|
/// Sweep идемпотентен (без работы — без записей), поэтому цикл
|
||||||
|
/// «запись → эмиссия стрима → повторный запрос» затухает сам.
|
||||||
|
Future<void> _requestSweep(String userId) async {
|
||||||
|
if (_sweeping) {
|
||||||
|
_sweepRequested = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_sweeping = true;
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
_sweepRequested = false;
|
||||||
|
await ref.read(parsingPipelineProvider).sweepPairs(userId);
|
||||||
|
} while (_sweepRequested);
|
||||||
|
} finally {
|
||||||
|
_sweeping = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drain текущего снапшота `pending` — явный триггер при обратном включении
|
/// Drain текущего снапшота `pending` — явный триггер при обратном включении
|
||||||
|
|||||||
@@ -19,18 +19,21 @@ Future<ParseRule?> parseRuleById(Ref ref, String id) =>
|
|||||||
ref.watch(parseRulesRepositoryProvider).findById(id);
|
ref.watch(parseRulesRepositoryProvider).findById(id);
|
||||||
|
|
||||||
/// Live-превью (§9.3): сообщения за 30 дней, совпадающие с паттерном.
|
/// Live-превью (§9.3): сообщения за 30 дней, совпадающие с паттерном.
|
||||||
|
/// [packageName] скоупит превью до приложения правила (null — без фильтра).
|
||||||
@riverpod
|
@riverpod
|
||||||
Future<List<RawMessage>> ruleMatchPreview(
|
Future<List<RawMessage>> ruleMatchPreview(
|
||||||
Ref ref,
|
Ref ref,
|
||||||
String userId,
|
String userId,
|
||||||
String pattern,
|
String pattern,
|
||||||
MatchMode mode,
|
MatchMode mode,
|
||||||
|
String? packageName,
|
||||||
) async {
|
) async {
|
||||||
if (pattern.trim().isEmpty) return const [];
|
if (pattern.trim().isEmpty) return const [];
|
||||||
final since = DateTime.now().subtract(const Duration(days: 30));
|
final since = DateTime.now().subtract(const Duration(days: 30));
|
||||||
final messages =
|
final messages =
|
||||||
await ref.watch(rawMessagesRepositoryProvider).recentByUser(userId, since);
|
await ref.watch(rawMessagesRepositoryProvider).recentByUser(userId, since);
|
||||||
return messages
|
return messages
|
||||||
|
.where((m) => packageName == null || m.packageName == packageName)
|
||||||
.where((m) => patternMatches(pattern, mode, body: m.body))
|
.where((m) => patternMatches(pattern, mode, body: m.body))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
@@ -43,6 +46,7 @@ class RulesController extends _$RulesController {
|
|||||||
|
|
||||||
Future<ParseRule> create({
|
Future<ParseRule> create({
|
||||||
required String userId,
|
required String userId,
|
||||||
|
required String packageName,
|
||||||
required ParseRuleKind kind,
|
required ParseRuleKind kind,
|
||||||
required MatchMode matchMode,
|
required MatchMode matchMode,
|
||||||
required String pattern,
|
required String pattern,
|
||||||
@@ -55,6 +59,7 @@ class RulesController extends _$RulesController {
|
|||||||
try {
|
try {
|
||||||
final rule = await ref.read(parseRulesRepositoryProvider).create(
|
final rule = await ref.read(parseRulesRepositoryProvider).create(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
|
packageName: packageName,
|
||||||
kind: kind,
|
kind: kind,
|
||||||
matchMode: matchMode,
|
matchMode: matchMode,
|
||||||
pattern: pattern,
|
pattern: pattern,
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ class SourceAppsController extends _$SourceAppsController {
|
|||||||
Future<void> setEnabled(String id, {required bool enabled}) =>
|
Future<void> setEnabled(String id, {required bool enabled}) =>
|
||||||
ref.read(sourceAppsRepositoryProvider).setEnabled(id, enabled: enabled);
|
ref.read(sourceAppsRepositoryProvider).setEnabled(id, enabled: enabled);
|
||||||
|
|
||||||
|
Future<void> setSelfMerchant(String id, {required bool value}) =>
|
||||||
|
ref.read(sourceAppsRepositoryProvider).setSelfMerchant(id, value: value);
|
||||||
|
|
||||||
Future<void> delete(String id) =>
|
Future<void> delete(String id) =>
|
||||||
ref.read(sourceAppsRepositoryProvider).deleteById(id);
|
ref.read(sourceAppsRepositoryProvider).deleteById(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,17 @@ String buildSystemPrompt({required List<String> categoryNames}) {
|
|||||||
- "counterpartyPhone": телефон контрагента (для переводов по СБП), либо null.
|
- "counterpartyPhone": телефон контрагента (для переводов по СБП), либо null.
|
||||||
- "dateTime": ISO-8601 дата-время операции из текста, либо null.
|
- "dateTime": ISO-8601 дата-время операции из текста, либо null.
|
||||||
- "kind": одно из "purchase" | "refund" | "transfer_out" | "transfer_in" | "fee" | "balance" | "other".
|
- "kind": одно из "purchase" | "refund" | "transfer_out" | "transfer_in" | "fee" | "balance" | "other".
|
||||||
|
|
||||||
|
Правила выбора "kind" и "type". "type" — экономический смысл для бюджета,
|
||||||
|
"kind" — механика операции; жёсткой связки kind=transfer_* → type=transfer НЕТ:
|
||||||
|
- "transfer_out" — исходящий перевод денег («вы перевели», «перевод по СБП»,
|
||||||
|
«перевод на карту/счёт»), НЕ оплата товара/услуги у продавца. type="expense".
|
||||||
|
- "transfer_in" — входящее зачисление переводом от физлица или со своего счёта
|
||||||
|
(«N перевёл(а) вам», «Пополнение с карты *1234», «перевод из банка»).
|
||||||
|
type="income".
|
||||||
|
- Зарплата, соцвыплаты, проценты, кэшбек и прочие зачисления от организаций —
|
||||||
|
это НЕ переводы: kind="other", type="income".
|
||||||
|
- Оплата покупки — kind="purchase", type="expense".
|
||||||
- "categorySuggestion": наиболее подходящая категория из списка пользователя
|
- "categorySuggestion": наиболее подходящая категория из списка пользователя
|
||||||
(точное название из списка) или null, если ничего не подходит.
|
(точное название из списка) или null, если ничего не подходит.
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,17 @@ class ParseRulesDao extends DatabaseAccessor<AppDatabase>
|
|||||||
Future<List<ParseRulesTableData>> getByUser(String userId) =>
|
Future<List<ParseRulesTableData>> getByUser(String userId) =>
|
||||||
(select(parseRulesTable)..where((t) => t.userId.equals(userId))).get();
|
(select(parseRulesTable)..where((t) => t.userId.equals(userId))).get();
|
||||||
|
|
||||||
/// Только активные правила — для pipeline (rule_lookup).
|
/// Активные правила приложения [packageName] — для pipeline (rule_lookup).
|
||||||
Future<List<ParseRulesTableData>> getEnabledByUser(String userId) =>
|
/// NULL-строки (легаси без привязки) матчатся в любом приложении.
|
||||||
|
Future<List<ParseRulesTableData>> getEnabledForApp(
|
||||||
|
String userId,
|
||||||
|
String packageName,
|
||||||
|
) =>
|
||||||
(select(parseRulesTable)
|
(select(parseRulesTable)
|
||||||
..where((t) =>
|
..where((t) =>
|
||||||
t.userId.equals(userId) & t.enabled.equals(true))
|
t.userId.equals(userId) &
|
||||||
|
t.enabled.equals(true) &
|
||||||
|
(t.packageName.equals(packageName) | t.packageName.isNull()))
|
||||||
..orderBy([
|
..orderBy([
|
||||||
(t) => OrderingTerm.desc(t.priority),
|
(t) => OrderingTerm.desc(t.priority),
|
||||||
(t) => OrderingTerm.desc(t.matchCount),
|
(t) => OrderingTerm.desc(t.matchCount),
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import '../../../../../core/database/app_database.dart';
|
import '../../../../../core/database/app_database.dart';
|
||||||
|
import '../../../../../core/database/converters/enum_converters.dart';
|
||||||
|
import '../../../../../core/database/tables/transactions_table.dart';
|
||||||
import '../tables/raw_messages_table.dart';
|
import '../tables/raw_messages_table.dart';
|
||||||
import '../../../domain/enums.dart';
|
import '../../../domain/enums.dart';
|
||||||
|
|
||||||
part 'raw_messages_dao.g.dart';
|
part 'raw_messages_dao.g.dart';
|
||||||
|
|
||||||
@DriftAccessor(tables: [RawMessagesTable])
|
@DriftAccessor(tables: [RawMessagesTable, TransactionsTable])
|
||||||
class RawMessagesDao extends DatabaseAccessor<AppDatabase>
|
class RawMessagesDao extends DatabaseAccessor<AppDatabase>
|
||||||
with _$RawMessagesDaoMixin {
|
with _$RawMessagesDaoMixin {
|
||||||
RawMessagesDao(super.db);
|
RawMessagesDao(super.db);
|
||||||
@@ -43,6 +45,17 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
|
|||||||
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)]))
|
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)]))
|
||||||
.watch();
|
.watch();
|
||||||
|
|
||||||
|
/// Поток половинок переводов, ждущих пару, — триггер sweep и перевзвод
|
||||||
|
/// таймера дедлайна в ParsingWorker. Порядок — по receivedAt, чтобы sweep
|
||||||
|
/// матчил детерминированно (старшая половинка первой).
|
||||||
|
Stream<List<RawMessagesTableData>> watchWaitingPair(String userId) =>
|
||||||
|
(select(rawMessagesTable)
|
||||||
|
..where((t) =>
|
||||||
|
t.userId.equals(userId) &
|
||||||
|
t.status.equalsValue(RawMessageStatus.waitingPair))
|
||||||
|
..orderBy([(t) => OrderingTerm.asc(t.receivedAt)]))
|
||||||
|
.watch();
|
||||||
|
|
||||||
/// Поток всех сообщений пользователя — для экрана «Журнал парсинга».
|
/// Поток всех сообщений пользователя — для экрана «Журнал парсинга».
|
||||||
/// Включает любые статусы (`applied`/`ignored`/`failed`/…), новые сверху.
|
/// Включает любые статусы (`applied`/`ignored`/`failed`/…), новые сверху.
|
||||||
Stream<List<RawMessagesTableData>> watchAll(String userId, {int limit = 200}) =>
|
Stream<List<RawMessagesTableData>> watchAll(String userId, {int limit = 200}) =>
|
||||||
@@ -65,6 +78,39 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
|
|||||||
.map((rows) => rows.isEmpty ? 0 : (rows.first.data['c'] as int? ?? 0));
|
.map((rows) => rows.isEmpty ? 0 : (rows.first.data['c'] as int? ?? 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Частые категории подтверждённых транзакций из уведомлений приложения
|
||||||
|
/// [packageName] — для чипов быстрого выбора на карточке Inbox. Порядок:
|
||||||
|
/// по числу транзакций, при равенстве — по свежести.
|
||||||
|
Stream<List<String>> watchTopCategoryIdsForPackage(
|
||||||
|
String userId,
|
||||||
|
String packageName,
|
||||||
|
TransactionType type, {
|
||||||
|
int limit = 4,
|
||||||
|
}) {
|
||||||
|
final query = customSelect(
|
||||||
|
'''
|
||||||
|
SELECT t.category_id AS cat
|
||||||
|
FROM transactions t
|
||||||
|
JOIN raw_messages m ON m.id = t.raw_message_id
|
||||||
|
WHERE t.user_id = ?1 AND m.package_name = ?2 AND t.type = ?3
|
||||||
|
AND t.category_id IS NOT NULL
|
||||||
|
GROUP BY t.category_id
|
||||||
|
ORDER BY COUNT(*) DESC, MAX(t.date) DESC
|
||||||
|
LIMIT ?4
|
||||||
|
''',
|
||||||
|
variables: [
|
||||||
|
Variable<String>(userId),
|
||||||
|
Variable<String>(packageName),
|
||||||
|
Variable<String>(const TransactionTypeConverter().toSql(type)),
|
||||||
|
Variable<int>(limit),
|
||||||
|
],
|
||||||
|
readsFrom: {rawMessagesTable, transactionsTable},
|
||||||
|
);
|
||||||
|
return query
|
||||||
|
.watch()
|
||||||
|
.map((rows) => rows.map((r) => r.data['cat'] as String).toList());
|
||||||
|
}
|
||||||
|
|
||||||
// ── Lookups ────────────────────────────────────────────────────────────────
|
// ── Lookups ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<RawMessagesTableData?> findById(String id) =>
|
Future<RawMessagesTableData?> findById(String id) =>
|
||||||
@@ -96,6 +142,22 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
|
|||||||
..limit(1))
|
..limit(1))
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
|
|
||||||
|
/// Сообщения в [statuses] с `receivedAt ∈ [from, to]` — кандидаты в
|
||||||
|
/// контрпартнёры пары для sweep (фильтр по kind делается в Dart по draftJson).
|
||||||
|
Future<List<RawMessagesTableData>> findRecentByStatuses(
|
||||||
|
String userId,
|
||||||
|
DateTime from,
|
||||||
|
DateTime to,
|
||||||
|
List<RawMessageStatus> statuses,
|
||||||
|
) =>
|
||||||
|
(select(rawMessagesTable)
|
||||||
|
..where((t) =>
|
||||||
|
t.userId.equals(userId) &
|
||||||
|
t.receivedAt.isBetweenValues(from, to) &
|
||||||
|
t.status.isInValues(statuses))
|
||||||
|
..orderBy([(t) => OrderingTerm.asc(t.receivedAt)]))
|
||||||
|
.get();
|
||||||
|
|
||||||
// ── Mutations ──────────────────────────────────────────────────────────────
|
// ── Mutations ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<void> insert(RawMessagesTableCompanion companion) =>
|
Future<void> insert(RawMessagesTableCompanion companion) =>
|
||||||
@@ -140,6 +202,58 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Паркует transfer-половинку в ожидание пары: draft сохранён, AI повторно
|
||||||
|
/// не зовётся; sweep либо склеит её, либо релизнет в Inbox по [deadline].
|
||||||
|
Future<void> holdForPairing({
|
||||||
|
required String id,
|
||||||
|
required String draftJson,
|
||||||
|
required DateTime deadline,
|
||||||
|
int? confidenceAmount,
|
||||||
|
int? confidenceAccount,
|
||||||
|
int? confidenceType,
|
||||||
|
int? confidenceMerchant,
|
||||||
|
int? confidenceCategory,
|
||||||
|
}) =>
|
||||||
|
(update(rawMessagesTable)..where((t) => t.id.equals(id))).write(
|
||||||
|
RawMessagesTableCompanion(
|
||||||
|
status: const Value(RawMessageStatus.waitingPair),
|
||||||
|
draftJson: Value(draftJson),
|
||||||
|
pairDeadline: Value(deadline),
|
||||||
|
confidenceAmount: Value(confidenceAmount),
|
||||||
|
confidenceAccount: Value(confidenceAccount),
|
||||||
|
confidenceType: Value(confidenceType),
|
||||||
|
confidenceMerchant: Value(confidenceMerchant),
|
||||||
|
confidenceCategory: Value(confidenceCategory),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Связывает половинку с парой ([pairedWithId]) и, опционально, меняет
|
||||||
|
/// статус (secondary → paired). Дедлайн ожидания при этом снимается.
|
||||||
|
Future<void> setPairing(String id, String pairedWithId,
|
||||||
|
{RawMessageStatus? status}) =>
|
||||||
|
(update(rawMessagesTable)..where((t) => t.id.equals(id))).write(
|
||||||
|
RawMessagesTableCompanion(
|
||||||
|
pairedWithId: Value(pairedWithId),
|
||||||
|
pairDeadline: const Value(null),
|
||||||
|
status: status == null ? const Value.absent() : Value(status),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Расклейка: снимает связь с парой (и дедлайн, если оставался).
|
||||||
|
Future<void> clearPairing(String id) =>
|
||||||
|
(update(rawMessagesTable)..where((t) => t.id.equals(id))).write(
|
||||||
|
const RawMessagesTableCompanion(
|
||||||
|
pairedWithId: Value(null),
|
||||||
|
pairDeadline: Value(null),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Откат доклейки: отвязка от транзакции (статус меняет вызывающий).
|
||||||
|
Future<void> unlinkTransaction(String id) =>
|
||||||
|
(update(rawMessagesTable)..where((t) => t.id.equals(id))).write(
|
||||||
|
const RawMessagesTableCompanion(transactionId: Value(null)),
|
||||||
|
);
|
||||||
|
|
||||||
/// Привязка к созданной транзакции (auto-apply).
|
/// Привязка к созданной транзакции (auto-apply).
|
||||||
Future<void> linkTransaction(String id, String transactionId) =>
|
Future<void> linkTransaction(String id, String transactionId) =>
|
||||||
(update(rawMessagesTable)..where((t) => t.id.equals(id))).write(
|
(update(rawMessagesTable)..where((t) => t.id.equals(id))).write(
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ class SourceAppsDao extends DatabaseAccessor<AppDatabase>
|
|||||||
(update(sourceAppsTable)..where((t) => t.id.equals(id)))
|
(update(sourceAppsTable)..where((t) => t.id.equals(id)))
|
||||||
.write(SourceAppsTableCompanion(enabled: Value(enabled)));
|
.write(SourceAppsTableCompanion(enabled: Value(enabled)));
|
||||||
|
|
||||||
|
Future<void> setSelfMerchant(String id, {required bool value}) =>
|
||||||
|
(update(sourceAppsTable)..where((t) => t.id.equals(id)))
|
||||||
|
.write(SourceAppsTableCompanion(selfMerchant: Value(value)));
|
||||||
|
|
||||||
Future<int> deleteById(String id) =>
|
Future<int> deleteById(String id) =>
|
||||||
(delete(sourceAppsTable)..where((t) => t.id.equals(id))).go();
|
(delete(sourceAppsTable)..where((t) => t.id.equals(id))).go();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import '../../../../../core/database/app_database.dart';
|
||||||
|
import '../tables/transfer_pairing_blocklist_table.dart';
|
||||||
|
|
||||||
|
part 'transfer_pairing_blocklist_dao.g.dart';
|
||||||
|
|
||||||
|
@DriftAccessor(tables: [TransferPairingBlocklistTable])
|
||||||
|
class TransferPairingBlocklistDao extends DatabaseAccessor<AppDatabase>
|
||||||
|
with _$TransferPairingBlocklistDaoMixin {
|
||||||
|
TransferPairingBlocklistDao(super.db);
|
||||||
|
|
||||||
|
Future<void> insertEntry(TransferPairingBlocklistTableCompanion companion) =>
|
||||||
|
into(transferPairingBlocklistTable).insert(companion);
|
||||||
|
|
||||||
|
Future<bool> contains(String userId, String signature) async {
|
||||||
|
final row = await (select(transferPairingBlocklistTable)
|
||||||
|
..where((t) => t.userId.equals(userId) & t.signature.equals(signature))
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
return row != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,12 @@ class ParseRulesTable extends Table {
|
|||||||
TextColumn get userId =>
|
TextColumn get userId =>
|
||||||
text().references(UsersTable, #id, onDelete: KeyAction.cascade)();
|
text().references(UsersTable, #id, onDelete: KeyAction.cascade)();
|
||||||
|
|
||||||
|
/// Приложение-источник, к которому привязано правило (per-app scope).
|
||||||
|
/// Nullable в SQL (упрощает миграцию), но все пути создания обязаны
|
||||||
|
/// передавать значение; NULL-строки матчатся в любом приложении (легаси).
|
||||||
|
/// Без FK на source_apps — зеркалим паттерн account_bindings.
|
||||||
|
TextColumn get packageName => text().nullable()();
|
||||||
|
|
||||||
/// merchantToCategory | senderToAccount | ignore
|
/// merchantToCategory | senderToAccount | ignore
|
||||||
TextColumn get kind => text().map(const ParseRuleKindConverter())();
|
TextColumn get kind => text().map(const ParseRuleKindConverter())();
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ class RawMessagesTable extends Table {
|
|||||||
.references(TransactionsTable, #id, onDelete: KeyAction.setNull)
|
.references(TransactionsTable, #id, onDelete: KeyAction.setNull)
|
||||||
.nullable()();
|
.nullable()();
|
||||||
|
|
||||||
|
/// id второй половинки склеенного перевода (transfer pairing).
|
||||||
|
/// У primary указывает на secondary и наоборот. Null — вне пары.
|
||||||
|
TextColumn get pairedWithId => text().nullable()();
|
||||||
|
|
||||||
|
/// Дедлайн ожидания пары для статуса `waitingPair`: по истечении sweep
|
||||||
|
/// релизит сообщение в Inbox одиночной карточкой.
|
||||||
|
DateTimeColumn get pairDeadline => dateTime().nullable()();
|
||||||
|
|
||||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ class SourceAppsTable extends Table {
|
|||||||
TextColumn get displayName => text().nullable()();
|
TextColumn get displayName => text().nullable()();
|
||||||
|
|
||||||
BoolColumn get enabled => boolean().withDefault(const Constant(true))();
|
BoolColumn get enabled => boolean().withDefault(const Constant(true))();
|
||||||
|
|
||||||
|
/// Уведомления не называют продавца: мерчант — само приложение (Ozon,
|
||||||
|
/// маркетплейсы). Pipeline глушит AI-подсказку категории и не предлагает
|
||||||
|
/// merchant→category правило для таких источников.
|
||||||
|
BoolColumn get selfMerchant => boolean().withDefault(const Constant(false))();
|
||||||
|
|
||||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ extension ParseRuleMapper on ParseRulesTableData {
|
|||||||
ParseRule toDomain() => ParseRule(
|
ParseRule toDomain() => ParseRule(
|
||||||
id: id,
|
id: id,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
|
packageName: packageName,
|
||||||
kind: kind,
|
kind: kind,
|
||||||
matchMode: matchMode,
|
matchMode: matchMode,
|
||||||
pattern: pattern,
|
pattern: pattern,
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ extension RawMessageMapper on RawMessagesTableData {
|
|||||||
confidenceMerchant: confidenceMerchant,
|
confidenceMerchant: confidenceMerchant,
|
||||||
confidenceCategory: confidenceCategory,
|
confidenceCategory: confidenceCategory,
|
||||||
transactionId: transactionId,
|
transactionId: transactionId,
|
||||||
|
pairedWithId: pairedWithId,
|
||||||
|
pairDeadline: pairDeadline,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ extension SourceAppMapper on SourceAppsTableData {
|
|||||||
packageName: packageName,
|
packageName: packageName,
|
||||||
displayName: displayName,
|
displayName: displayName,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
|
selfMerchant: selfMerchant,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ class DraftBundle {
|
|||||||
required this.draft,
|
required this.draft,
|
||||||
this.suggestion,
|
this.suggestion,
|
||||||
this.failedChecks = const {},
|
this.failedChecks = const {},
|
||||||
|
this.accountTrusted = false,
|
||||||
|
this.pairedRawMessageId,
|
||||||
|
this.preMerge,
|
||||||
|
this.mergeUndo,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ParseDraft draft;
|
final ParseDraft draft;
|
||||||
@@ -25,18 +29,43 @@ class DraftBundle {
|
|||||||
/// Gate-проверки, не пройденные при отправке в Inbox — для строки
|
/// Gate-проверки, не пройденные при отправке в Inbox — для строки
|
||||||
/// «почему не автоматически» в карточке/журнале.
|
/// «почему не автоматически» в карточке/журнале.
|
||||||
final Set<AutoApplyCheck> failedChecks;
|
final Set<AutoApplyCheck> failedChecks;
|
||||||
|
|
||||||
|
/// Снапшот `AccountResolution.trusted` на момент парсинга — sweep решает
|
||||||
|
/// по нему допустимость доклейки (resolution к тому моменту уже недоступен).
|
||||||
|
final bool accountTrusted;
|
||||||
|
|
||||||
|
/// id второй половинки склеенного перевода. != null → merged-карточка
|
||||||
|
/// «Перевод между счетами» (или доклеенная половинка при [mergeUndo]).
|
||||||
|
final String? pairedRawMessageId;
|
||||||
|
|
||||||
|
/// Снапшот полей primary-draft, перезаписанных склейкой
|
||||||
|
/// (`{'type', 'categoryId'}`) — для восстановления при расклейке из Inbox.
|
||||||
|
final Map<String, dynamic>? preMerge;
|
||||||
|
|
||||||
|
/// Снапшот полей транзакции до доклейки (`{'txId', 'prevType',
|
||||||
|
/// 'prevAccountId', 'prevCategoryId', 'prevTransferToAccountId'}`) —
|
||||||
|
/// для отката доклеенного перевода из журнала.
|
||||||
|
final Map<String, dynamic>? mergeUndo;
|
||||||
}
|
}
|
||||||
|
|
||||||
String encodeDraftBundle(
|
String encodeDraftBundle(
|
||||||
ParseDraft draft,
|
ParseDraft draft,
|
||||||
RuleSuggestion? suggestion, {
|
RuleSuggestion? suggestion, {
|
||||||
Set<AutoApplyCheck> failedChecks = const {},
|
Set<AutoApplyCheck> failedChecks = const {},
|
||||||
|
bool accountTrusted = false,
|
||||||
|
String? pairedRawMessageId,
|
||||||
|
Map<String, dynamic>? preMerge,
|
||||||
|
Map<String, dynamic>? mergeUndo,
|
||||||
}) {
|
}) {
|
||||||
return jsonEncode(<String, dynamic>{
|
return jsonEncode(<String, dynamic>{
|
||||||
'draft': _draftToJson(draft),
|
'draft': _draftToJson(draft),
|
||||||
if (suggestion != null) 'suggestion': _suggestionToJson(suggestion),
|
if (suggestion != null) 'suggestion': _suggestionToJson(suggestion),
|
||||||
if (failedChecks.isNotEmpty)
|
if (failedChecks.isNotEmpty)
|
||||||
'failedChecks': failedChecks.map((c) => c.name).toList(),
|
'failedChecks': failedChecks.map((c) => c.name).toList(),
|
||||||
|
if (accountTrusted) 'accountTrusted': true,
|
||||||
|
'pairedRawMessageId': ?pairedRawMessageId,
|
||||||
|
'preMerge': ?preMerge,
|
||||||
|
'mergeUndo': ?mergeUndo,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +79,10 @@ DraftBundle? decodeDraftBundle(String? json) {
|
|||||||
draft: _draftFromJson(draftMap),
|
draft: _draftFromJson(draftMap),
|
||||||
suggestion: suggMap == null ? null : _suggestionFromJson(suggMap),
|
suggestion: suggMap == null ? null : _suggestionFromJson(suggMap),
|
||||||
failedChecks: _failedChecksFromJson(map['failedChecks']),
|
failedChecks: _failedChecksFromJson(map['failedChecks']),
|
||||||
|
accountTrusted: map['accountTrusted'] as bool? ?? false,
|
||||||
|
pairedRawMessageId: map['pairedRawMessageId'] as String?,
|
||||||
|
preMerge: (map['preMerge'] as Map<String, dynamic>?),
|
||||||
|
mergeUndo: (map['mergeUndo'] as Map<String, dynamic>?),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,26 +75,6 @@ ParseRule? findSenderRule(
|
|||||||
return matches.isEmpty ? null : matches.first;
|
return matches.isEmpty ? null : matches.first;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ищет активный маркер `mixedMerchant` для мерчанта (категория варьируется).
|
|
||||||
///
|
|
||||||
/// Наличие такого правила подавляет предложение merchant→category-правила в
|
|
||||||
/// Inbox (§9.1): pipeline не строит `RuleSuggestion`, и карточка показывает
|
|
||||||
/// «подтвердить разово» вместо кнопки «Создать правило».
|
|
||||||
ParseRule? findMixedMerchantRule(
|
|
||||||
List<ParseRule> rules, {
|
|
||||||
required String body,
|
|
||||||
String? merchantRaw,
|
|
||||||
}) {
|
|
||||||
final matches = rules
|
|
||||||
.where((r) =>
|
|
||||||
r.enabled &&
|
|
||||||
r.kind == ParseRuleKind.mixedMerchant &&
|
|
||||||
ruleMatches(r, body: body, merchantRaw: merchantRaw))
|
|
||||||
.toList()
|
|
||||||
..sort(_bySpecificity);
|
|
||||||
return matches.isEmpty ? null : matches.first;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ищет активное правило-исключение (kind=ignore) для сообщения.
|
/// Ищет активное правило-исключение (kind=ignore) для сообщения.
|
||||||
ParseRule? findIgnoreRule(
|
ParseRule? findIgnoreRule(
|
||||||
List<ParseRule> rules, {
|
List<ParseRule> rules, {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import '../../domain/entities/parse_draft.dart';
|
||||||
|
import '../../domain/enums.dart';
|
||||||
|
|
||||||
|
/// Чистые функции матчинга половинок перевода (transfer pairing).
|
||||||
|
///
|
||||||
|
/// Пара = два уведомления о списании/зачислении одного перевода между своими
|
||||||
|
/// счетами. Асинхронные проверки (blocklist) делает вызывающий код
|
||||||
|
/// (`ParsingPipeline.sweepPairs`), здесь — только детерминированные предикаты.
|
||||||
|
|
||||||
|
/// Окно матчинга и ожидания пары: |receivedAt₁ − receivedAt₂| ≤ 5 мин;
|
||||||
|
/// столько же живёт статус `waitingPair` до релиза в Inbox.
|
||||||
|
const Duration kPairingWindow = Duration(minutes: 5);
|
||||||
|
|
||||||
|
/// Кандидат в пейринг: draft с гранулярным kind перевода. kind=null и все
|
||||||
|
/// прочие виды (purchase, fee, …) идут обычным путём.
|
||||||
|
bool isTransferHalf(ParseDraft draft) =>
|
||||||
|
draft.kind == TxKind.transferOut || draft.kind == TxKind.transferIn;
|
||||||
|
|
||||||
|
/// Предикат пары. [aReceivedAt]/[bReceivedAt] — времена получения сообщений
|
||||||
|
/// (в draft времени может не быть). Blocklist-сигнатуру проверяет вызывающий.
|
||||||
|
bool isPair({
|
||||||
|
required ParseDraft a,
|
||||||
|
required DateTime aReceivedAt,
|
||||||
|
required ParseDraft b,
|
||||||
|
required DateTime bReceivedAt,
|
||||||
|
}) {
|
||||||
|
// Противоположные kind'ы (out ↔ in).
|
||||||
|
final opposite =
|
||||||
|
(a.kind == TxKind.transferOut && b.kind == TxKind.transferIn) ||
|
||||||
|
(a.kind == TxKind.transferIn && b.kind == TxKind.transferOut);
|
||||||
|
if (!opposite) return false;
|
||||||
|
|
||||||
|
// v1: суммы равны точно (без комиссий/конвертаций), валюты совпадают.
|
||||||
|
if (a.amount != b.amount) return false;
|
||||||
|
if (a.currency != b.currency) return false;
|
||||||
|
|
||||||
|
if (aReceivedAt.difference(bReceivedAt).abs() > kPairingWindow) return false;
|
||||||
|
|
||||||
|
// Перевод самому себе на тот же счёт не бывает: если оба счёта разрешены
|
||||||
|
// и совпали — это не пара. Одинаковый packageName допустим
|
||||||
|
// (внутрибанковский перевод «между счетами»).
|
||||||
|
if (a.accountId != null && b.accountId != null && a.accountId == b.accountId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Из [candidates] выбирает ближайшего по `receivedAt` к [target].
|
||||||
|
/// Кандидаты — пары (значение, receivedAt); список не должен быть пустым.
|
||||||
|
T pickNearest<T>(
|
||||||
|
List<(T, DateTime)> candidates,
|
||||||
|
DateTime target,
|
||||||
|
) {
|
||||||
|
assert(candidates.isNotEmpty);
|
||||||
|
var best = candidates.first;
|
||||||
|
var bestDelta = best.$2.difference(target).abs();
|
||||||
|
for (final c in candidates.skip(1)) {
|
||||||
|
final delta = c.$2.difference(target).abs();
|
||||||
|
if (delta < bestDelta) {
|
||||||
|
best = c;
|
||||||
|
bestDelta = delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best.$1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Сигнатура пары сообщений для blocklist: порядконезависимая, чтобы
|
||||||
|
/// расклейка блокировала повторный матч с любой стороны.
|
||||||
|
String pairSignature(String idA, String idB) =>
|
||||||
|
([idA, idB]..sort()).join('|');
|
||||||
@@ -19,8 +19,13 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository {
|
|||||||
.map((rows) => rows.map((r) => r.toDomain()).toList());
|
.map((rows) => rows.map((r) => r.toDomain()).toList());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<ParseRule>> getEnabledByUser(String userId) async =>
|
Future<List<ParseRule>> getEnabledForApp(
|
||||||
(await _dao.getEnabledByUser(userId)).map((r) => r.toDomain()).toList();
|
String userId,
|
||||||
|
String packageName,
|
||||||
|
) async =>
|
||||||
|
(await _dao.getEnabledForApp(userId, packageName))
|
||||||
|
.map((r) => r.toDomain())
|
||||||
|
.toList();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<ParseRule>> getByUser(String userId) async =>
|
Future<List<ParseRule>> getByUser(String userId) async =>
|
||||||
@@ -33,6 +38,7 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository {
|
|||||||
@override
|
@override
|
||||||
Future<ParseRule> create({
|
Future<ParseRule> create({
|
||||||
required String userId,
|
required String userId,
|
||||||
|
required String packageName,
|
||||||
required ParseRuleKind kind,
|
required ParseRuleKind kind,
|
||||||
required MatchMode matchMode,
|
required MatchMode matchMode,
|
||||||
required String pattern,
|
required String pattern,
|
||||||
@@ -47,6 +53,7 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository {
|
|||||||
ParseRulesTableCompanion.insert(
|
ParseRulesTableCompanion.insert(
|
||||||
id: id,
|
id: id,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
|
packageName: Value(packageName),
|
||||||
kind: kind,
|
kind: kind,
|
||||||
pattern: pattern,
|
pattern: pattern,
|
||||||
matchMode: Value(matchMode),
|
matchMode: Value(matchMode),
|
||||||
@@ -65,6 +72,7 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository {
|
|||||||
Future<void> update(ParseRule rule) => _dao.updateRow(
|
Future<void> update(ParseRule rule) => _dao.updateRow(
|
||||||
ParseRulesTableCompanion(
|
ParseRulesTableCompanion(
|
||||||
id: Value(rule.id),
|
id: Value(rule.id),
|
||||||
|
packageName: Value(rule.packageName),
|
||||||
kind: Value(rule.kind),
|
kind: Value(rule.kind),
|
||||||
matchMode: Value(rule.matchMode),
|
matchMode: Value(rule.matchMode),
|
||||||
pattern: Value(rule.pattern),
|
pattern: Value(rule.pattern),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
|
|||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../../../../core/database/app_database.dart';
|
import '../../../../core/database/app_database.dart';
|
||||||
|
import '../../../../core/database/converters/enum_converters.dart';
|
||||||
import '../../domain/entities/raw_message.dart';
|
import '../../domain/entities/raw_message.dart';
|
||||||
import '../../domain/enums.dart';
|
import '../../domain/enums.dart';
|
||||||
import '../../domain/repositories/raw_messages_repository.dart';
|
import '../../domain/repositories/raw_messages_repository.dart';
|
||||||
@@ -32,9 +33,19 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
|
|||||||
Stream<List<RawMessage>> watchAll(String userId) =>
|
Stream<List<RawMessage>> watchAll(String userId) =>
|
||||||
_dao.watchAll(userId).map((rows) => rows.map((r) => r.toDomain()).toList());
|
_dao.watchAll(userId).map((rows) => rows.map((r) => r.toDomain()).toList());
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<RawMessage>> watchWaitingPair(String userId) => _dao
|
||||||
|
.watchWaitingPair(userId)
|
||||||
|
.map((rows) => rows.map((r) => r.toDomain()).toList());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<int> watchInboxCount(String userId) => _dao.watchInboxCount(userId);
|
Stream<int> watchInboxCount(String userId) => _dao.watchInboxCount(userId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<String>> watchTopCategoryIds(
|
||||||
|
String userId, String packageName, TransactionType type) =>
|
||||||
|
_dao.watchTopCategoryIdsForPackage(userId, packageName, type);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<RawMessage?> findById(String id) async =>
|
Future<RawMessage?> findById(String id) async =>
|
||||||
(await _dao.findById(id))?.toDomain();
|
(await _dao.findById(id))?.toDomain();
|
||||||
@@ -56,6 +67,17 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
|
|||||||
.map((r) => r.toDomain())
|
.map((r) => r.toDomain())
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<RawMessage>> findRecentByStatuses(
|
||||||
|
String userId,
|
||||||
|
DateTime from,
|
||||||
|
DateTime to,
|
||||||
|
List<RawMessageStatus> statuses,
|
||||||
|
) async =>
|
||||||
|
(await _dao.findRecentByStatuses(userId, from, to, statuses))
|
||||||
|
.map((r) => r.toDomain())
|
||||||
|
.toList();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<RawMessage> insertIncoming({
|
Future<RawMessage> insertIncoming({
|
||||||
required String userId,
|
required String userId,
|
||||||
@@ -130,6 +152,39 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
|
|||||||
Future<void> linkTransaction(String id, String transactionId) =>
|
Future<void> linkTransaction(String id, String transactionId) =>
|
||||||
_dao.linkTransaction(id, transactionId);
|
_dao.linkTransaction(id, transactionId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> holdForPairing({
|
||||||
|
required String id,
|
||||||
|
required String draftJson,
|
||||||
|
required DateTime deadline,
|
||||||
|
int? confidenceAmount,
|
||||||
|
int? confidenceAccount,
|
||||||
|
int? confidenceType,
|
||||||
|
int? confidenceMerchant,
|
||||||
|
int? confidenceCategory,
|
||||||
|
}) =>
|
||||||
|
_dao.holdForPairing(
|
||||||
|
id: id,
|
||||||
|
draftJson: draftJson,
|
||||||
|
deadline: deadline,
|
||||||
|
confidenceAmount: confidenceAmount,
|
||||||
|
confidenceAccount: confidenceAccount,
|
||||||
|
confidenceType: confidenceType,
|
||||||
|
confidenceMerchant: confidenceMerchant,
|
||||||
|
confidenceCategory: confidenceCategory,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setPairing(String id, String pairedWithId,
|
||||||
|
{RawMessageStatus? status}) =>
|
||||||
|
_dao.setPairing(id, pairedWithId, status: status);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clearPairing(String id) => _dao.clearPairing(id);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> unlinkTransaction(String id) => _dao.unlinkTransaction(id);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> incrementParseAttempts(String id) =>
|
Future<void> incrementParseAttempts(String id) =>
|
||||||
_dao.incrementParseAttempts(id);
|
_dao.incrementParseAttempts(id);
|
||||||
|
|||||||
@@ -42,10 +42,18 @@ class SourceAppsRepositoryImpl implements SourceAppsRepository {
|
|||||||
return row!.toDomain();
|
return row!.toDomain();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SourceApp?> findByPackageName(String userId, String packageName) async =>
|
||||||
|
(await _dao.findByPackageName(userId, packageName))?.toDomain();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setEnabled(String id, {required bool enabled}) =>
|
Future<void> setEnabled(String id, {required bool enabled}) =>
|
||||||
_dao.setEnabled(id, enabled: enabled);
|
_dao.setEnabled(id, enabled: enabled);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setSelfMerchant(String id, {required bool value}) =>
|
||||||
|
_dao.setSelfMerchant(id, value: value);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteById(String id) => _dao.deleteById(id);
|
Future<void> deleteById(String id) => _dao.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../../../../core/database/app_database.dart';
|
||||||
|
import '../../domain/repositories/transfer_pairing_blocklist_repository.dart';
|
||||||
|
import '../drift/daos/transfer_pairing_blocklist_dao.dart';
|
||||||
|
|
||||||
|
class TransferPairingBlocklistRepositoryImpl
|
||||||
|
implements TransferPairingBlocklistRepository {
|
||||||
|
const TransferPairingBlocklistRepositoryImpl(this._dao);
|
||||||
|
final TransferPairingBlocklistDao _dao;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> add(String userId, String signature) async {
|
||||||
|
// Идемпотентно: повторная расклейка той же пары не плодит дублей.
|
||||||
|
if (await _dao.contains(userId, signature)) return;
|
||||||
|
await _dao.insertEntry(
|
||||||
|
TransferPairingBlocklistTableCompanion.insert(
|
||||||
|
id: const Uuid().v4(),
|
||||||
|
userId: userId,
|
||||||
|
signature: signature,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> contains(String userId, String signature) =>
|
||||||
|
_dao.contains(userId, signature);
|
||||||
|
}
|
||||||
@@ -20,6 +20,10 @@ abstract class ParseRule with _$ParseRule {
|
|||||||
const factory ParseRule({
|
const factory ParseRule({
|
||||||
required String id,
|
required String id,
|
||||||
required String userId,
|
required String userId,
|
||||||
|
|
||||||
|
/// Приложение-источник, к которому привязано правило. null — легаси
|
||||||
|
/// (глобальное правило, матчится в любом приложении).
|
||||||
|
String? packageName,
|
||||||
required ParseRuleKind kind,
|
required ParseRuleKind kind,
|
||||||
required MatchMode matchMode,
|
required MatchMode matchMode,
|
||||||
required String pattern,
|
required String pattern,
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ abstract class RawMessage with _$RawMessage {
|
|||||||
|
|
||||||
/// FK на transactions.id — заполняется когда статус applied.
|
/// FK на transactions.id — заполняется когда статус applied.
|
||||||
String? transactionId,
|
String? transactionId,
|
||||||
|
|
||||||
|
/// id второй половинки склеенного перевода (у primary — secondary и
|
||||||
|
/// наоборот). Null — вне пары.
|
||||||
|
String? pairedWithId,
|
||||||
|
|
||||||
|
/// Дедлайн ожидания пары (только для статуса waitingPair).
|
||||||
|
DateTime? pairDeadline,
|
||||||
required DateTime createdAt,
|
required DateTime createdAt,
|
||||||
}) = _RawMessage;
|
}) = _RawMessage;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ abstract class SourceApp with _$SourceApp {
|
|||||||
required String packageName,
|
required String packageName,
|
||||||
String? displayName,
|
String? displayName,
|
||||||
@Default(true) bool enabled,
|
@Default(true) bool enabled,
|
||||||
|
|
||||||
|
/// Уведомления не называют продавца: мерчант — само приложение (Ozon).
|
||||||
|
@Default(false) bool selfMerchant,
|
||||||
required DateTime createdAt,
|
required DateTime createdAt,
|
||||||
}) = _SourceApp;
|
}) = _SourceApp;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ enum RawMessageStatus {
|
|||||||
applied,
|
applied,
|
||||||
ignored,
|
ignored,
|
||||||
failed,
|
failed,
|
||||||
|
|
||||||
|
/// Распарсенная transfer-половинка скрыто ждёт вторую половинку пары
|
||||||
|
/// (окно до `pairDeadline`); по таймауту уходит в Inbox одиночкой.
|
||||||
|
waitingPair,
|
||||||
|
|
||||||
|
/// Вторичная половинка склеенного перевода: скрыта из Inbox, в журнале
|
||||||
|
/// показывается как «объединено в перевод».
|
||||||
|
paired,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -24,11 +32,6 @@ enum ParseRuleKind {
|
|||||||
merchantToCategory,
|
merchantToCategory,
|
||||||
senderToAccount,
|
senderToAccount,
|
||||||
ignore,
|
ignore,
|
||||||
|
|
||||||
/// Маркер «у мерчанта категория варьируется» (Ozon, маркетплейсы): не задаёт
|
|
||||||
/// действия, а подавляет предложение merchant→category-правила в Inbox.
|
|
||||||
/// Создаётся явным действием пользователя «Категории различаются».
|
|
||||||
mixedMerchant,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ abstract interface class ParseRulesRepository {
|
|||||||
/// Все правила пользователя — для экрана «Правила парсинга».
|
/// Все правила пользователя — для экрана «Правила парсинга».
|
||||||
Stream<List<ParseRule>> watchByUser(String userId);
|
Stream<List<ParseRule>> watchByUser(String userId);
|
||||||
|
|
||||||
/// Только активные правила — для pipeline (rule_lookup).
|
/// Активные правила приложения [packageName] (+ легаси-строки без
|
||||||
Future<List<ParseRule>> getEnabledByUser(String userId);
|
/// packageName) — для pipeline (rule_lookup).
|
||||||
|
Future<List<ParseRule>> getEnabledForApp(String userId, String packageName);
|
||||||
|
|
||||||
Future<List<ParseRule>> getByUser(String userId);
|
Future<List<ParseRule>> getByUser(String userId);
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ abstract interface class ParseRulesRepository {
|
|||||||
/// Создаёт правило (активно сразу, weight=1). Возвращает сохранённую сущность.
|
/// Создаёт правило (активно сразу, weight=1). Возвращает сохранённую сущность.
|
||||||
Future<ParseRule> create({
|
Future<ParseRule> create({
|
||||||
required String userId,
|
required String userId,
|
||||||
|
required String packageName,
|
||||||
required ParseRuleKind kind,
|
required ParseRuleKind kind,
|
||||||
required MatchMode matchMode,
|
required MatchMode matchMode,
|
||||||
required String pattern,
|
required String pattern,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import '../../../../core/database/converters/enum_converters.dart';
|
||||||
import '../entities/raw_message.dart';
|
import '../entities/raw_message.dart';
|
||||||
import '../enums.dart';
|
import '../enums.dart';
|
||||||
|
|
||||||
@@ -19,9 +20,18 @@ abstract interface class RawMessagesRepository {
|
|||||||
/// Все сообщения пользователя (любой статус) — для «Журнала парсинга».
|
/// Все сообщения пользователя (любой статус) — для «Журнала парсинга».
|
||||||
Stream<List<RawMessage>> watchAll(String userId);
|
Stream<List<RawMessage>> watchAll(String userId);
|
||||||
|
|
||||||
|
/// Половинки переводов, ждущие пару (`waitingPair`) — триггер sweep
|
||||||
|
/// и таймера дедлайна в ParsingWorker.
|
||||||
|
Stream<List<RawMessage>> watchWaitingPair(String userId);
|
||||||
|
|
||||||
/// Реактивный счётчик Inbox — для бэджа на Home.
|
/// Реактивный счётчик Inbox — для бэджа на Home.
|
||||||
Stream<int> watchInboxCount(String userId);
|
Stream<int> watchInboxCount(String userId);
|
||||||
|
|
||||||
|
/// Частые категории подтверждённых транзакций из уведомлений приложения —
|
||||||
|
/// для чипов быстрого выбора на карточке Inbox (по частоте, затем свежести).
|
||||||
|
Stream<List<String>> watchTopCategoryIds(
|
||||||
|
String userId, String packageName, TransactionType type);
|
||||||
|
|
||||||
Future<RawMessage?> findById(String id);
|
Future<RawMessage?> findById(String id);
|
||||||
|
|
||||||
/// Поиск дубля: совпадение [dedupHash] **и** `receivedAt` в пределах
|
/// Поиск дубля: совпадение [dedupHash] **и** `receivedAt` в пределах
|
||||||
@@ -33,6 +43,15 @@ abstract interface class RawMessagesRepository {
|
|||||||
/// Сообщения за период (для live-превью правил).
|
/// Сообщения за период (для live-превью правил).
|
||||||
Future<List<RawMessage>> recentByUser(String userId, DateTime since);
|
Future<List<RawMessage>> recentByUser(String userId, DateTime since);
|
||||||
|
|
||||||
|
/// Сообщения в [statuses] с `receivedAt ∈ [from, to]` — кандидаты в
|
||||||
|
/// контрпартнёры пары для sweep.
|
||||||
|
Future<List<RawMessage>> findRecentByStatuses(
|
||||||
|
String userId,
|
||||||
|
DateTime from,
|
||||||
|
DateTime to,
|
||||||
|
List<RawMessageStatus> statuses,
|
||||||
|
);
|
||||||
|
|
||||||
/// Идемпотентная вставка нового уведомления. Если сообщение с тем же
|
/// Идемпотентная вставка нового уведомления. Если сообщение с тем же
|
||||||
/// dedup-хэшем уже есть **в пределах дедуп-окна по [receivedAt]** —
|
/// dedup-хэшем уже есть **в пределах дедуп-окна по [receivedAt]** —
|
||||||
/// возвращает существующее, не создавая дубль. Тот же текст вне окна
|
/// возвращает существующее, не создавая дубль. Тот же текст вне окна
|
||||||
@@ -67,6 +86,30 @@ abstract interface class RawMessagesRepository {
|
|||||||
/// Привязка к созданной транзакции (status → applied).
|
/// Привязка к созданной транзакции (status → applied).
|
||||||
Future<void> linkTransaction(String id, String transactionId);
|
Future<void> linkTransaction(String id, String transactionId);
|
||||||
|
|
||||||
|
/// Паркует transfer-половинку в ожидание пары (status → waitingPair,
|
||||||
|
/// draft сохранён, дедлайн взведён).
|
||||||
|
Future<void> holdForPairing({
|
||||||
|
required String id,
|
||||||
|
required String draftJson,
|
||||||
|
required DateTime deadline,
|
||||||
|
int? confidenceAmount,
|
||||||
|
int? confidenceAccount,
|
||||||
|
int? confidenceType,
|
||||||
|
int? confidenceMerchant,
|
||||||
|
int? confidenceCategory,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Связывает половинку с парой; [status] — опциональная смена статуса
|
||||||
|
/// (secondary → paired).
|
||||||
|
Future<void> setPairing(String id, String pairedWithId,
|
||||||
|
{RawMessageStatus? status});
|
||||||
|
|
||||||
|
/// Расклейка: снимает связь с парой.
|
||||||
|
Future<void> clearPairing(String id);
|
||||||
|
|
||||||
|
/// Откат доклейки: отвязка от транзакции (статус меняет вызывающий).
|
||||||
|
Future<void> unlinkTransaction(String id);
|
||||||
|
|
||||||
Future<void> incrementParseAttempts(String id);
|
Future<void> incrementParseAttempts(String id);
|
||||||
|
|
||||||
/// Сброс сообщения на повторную обработку (status → pending, попытки → 0).
|
/// Сброс сообщения на повторную обработку (status → pending, попытки → 0).
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ abstract interface class SourceAppsRepository {
|
|||||||
/// Стрим, чтобы фильтр и native-синк реагировали на изменения allowlist.
|
/// Стрим, чтобы фильтр и native-синк реагировали на изменения allowlist.
|
||||||
Stream<Set<String>> watchEnabledPackages(String userId);
|
Stream<Set<String>> watchEnabledPackages(String userId);
|
||||||
|
|
||||||
|
/// Приложение по packageName — снапшот для pipeline (allowlist + флаг
|
||||||
|
/// [SourceApp.selfMerchant]). null = приложение не добавлено.
|
||||||
|
Future<SourceApp?> findByPackageName(String userId, String packageName);
|
||||||
|
|
||||||
/// Добавляет приложение (включено по умолчанию). Возвращает сущность.
|
/// Добавляет приложение (включено по умолчанию). Возвращает сущность.
|
||||||
/// Если строка с таким packageName уже есть — возвращает существующую.
|
/// Если строка с таким packageName уже есть — возвращает существующую.
|
||||||
Future<SourceApp> add({
|
Future<SourceApp> add({
|
||||||
@@ -19,5 +23,8 @@ abstract interface class SourceAppsRepository {
|
|||||||
|
|
||||||
Future<void> setEnabled(String id, {required bool enabled});
|
Future<void> setEnabled(String id, {required bool enabled});
|
||||||
|
|
||||||
|
/// Флаг «мерчант — само приложение» (Ozon, маркетплейсы).
|
||||||
|
Future<void> setSelfMerchant(String id, {required bool value});
|
||||||
|
|
||||||
Future<void> deleteById(String id);
|
Future<void> deleteById(String id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/// Blocklist расклеенных пар переводов.
|
||||||
|
///
|
||||||
|
/// v1: сигнатура — разовая, по конкретной паре сообщений
|
||||||
|
/// (`pairSignature(msgIdA, msgIdB)`), не вечный бан пары счетов. Расклейка
|
||||||
|
/// пишет сигнатуру, чтобы sweep не склеил ту же пару снова.
|
||||||
|
abstract interface class TransferPairingBlocklistRepository {
|
||||||
|
Future<void> add(String userId, String signature);
|
||||||
|
|
||||||
|
Future<bool> contains(String userId, String signature);
|
||||||
|
}
|
||||||
@@ -79,31 +79,15 @@ class InboxScreen extends ConsumerWidget {
|
|||||||
top: false,
|
top: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 8),
|
padding: const EdgeInsets.fromLTRB(12, 4, 12, 8),
|
||||||
child: Row(
|
child: SizedBox(
|
||||||
children: [
|
width: double.infinity,
|
||||||
Expanded(
|
child: FilledButton(
|
||||||
child: FilledButton(
|
style: FilledButton.styleFrom(backgroundColor: p.accent),
|
||||||
style: FilledButton.styleFrom(backgroundColor: p.accent),
|
onPressed: () => ref
|
||||||
onPressed: () => ref
|
.read(inboxControllerProvider.notifier)
|
||||||
.read(inboxControllerProvider.notifier)
|
.applyAll(userId),
|
||||||
.applyAll(userId),
|
child: Text(l10n.inboxApplyAll),
|
||||||
child: Text(l10n.inboxApplyAll),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton(
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: p.ink,
|
|
||||||
side: BorderSide(color: p.line),
|
|
||||||
),
|
|
||||||
onPressed: () => ref
|
|
||||||
.read(inboxControllerProvider.notifier)
|
|
||||||
.hideAll(userId),
|
|
||||||
child: Text(l10n.inboxHideAll),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ class _ParsingLogScreenState extends ConsumerState<ParsingLogScreen> {
|
|||||||
_LogFilter.waiting => m.status == RawMessageStatus.pendingAi ||
|
_LogFilter.waiting => m.status == RawMessageStatus.pendingAi ||
|
||||||
m.status == RawMessageStatus.pending ||
|
m.status == RawMessageStatus.pending ||
|
||||||
m.status == RawMessageStatus.parsing ||
|
m.status == RawMessageStatus.parsing ||
|
||||||
m.status == RawMessageStatus.parsed,
|
m.status == RawMessageStatus.parsed ||
|
||||||
|
m.status == RawMessageStatus.waitingPair,
|
||||||
_LogFilter.inbox => m.status == RawMessageStatus.inbox ||
|
_LogFilter.inbox => m.status == RawMessageStatus.inbox ||
|
||||||
m.status == RawMessageStatus.parsedPartial,
|
m.status == RawMessageStatus.parsedPartial,
|
||||||
_LogFilter.applied => m.status == RawMessageStatus.applied,
|
_LogFilter.applied => m.status == RawMessageStatus.applied,
|
||||||
@@ -268,6 +269,30 @@ class _LogRowState extends ConsumerState<_LogRow> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_expanded) _DetailPanel(message: message, bundle: bundle),
|
if (_expanded) _DetailPanel(message: message, bundle: bundle),
|
||||||
|
// Откат доклеенного перевода: кнопка живёт, пока половинка
|
||||||
|
// привязана к транзакции (после отката bundle теряет mergeUndo).
|
||||||
|
if (_expanded &&
|
||||||
|
bundle?.mergeUndo != null &&
|
||||||
|
message.transactionId != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () => ref
|
||||||
|
.read(inboxControllerProvider.notifier)
|
||||||
|
.unmergeApplied(userId: message.userId, message: message),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: p.ink,
|
||||||
|
side: BorderSide(color: p.line),
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.link_off, size: 16),
|
||||||
|
label: Text(l10n.parsingUnmergeTransfer,
|
||||||
|
style: const TextStyle(fontSize: 13)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
if (canRetry) ...[
|
if (canRetry) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Align(
|
Align(
|
||||||
@@ -593,5 +618,7 @@ class _StatusBadge extends StatelessWidget {
|
|||||||
RawMessageStatus.applied => l10n.parsingStatusApplied,
|
RawMessageStatus.applied => l10n.parsingStatusApplied,
|
||||||
RawMessageStatus.ignored => l10n.parsingStatusIgnored,
|
RawMessageStatus.ignored => l10n.parsingStatusIgnored,
|
||||||
RawMessageStatus.failed => l10n.parsingStatusFailed,
|
RawMessageStatus.failed => l10n.parsingStatusFailed,
|
||||||
|
RawMessageStatus.waitingPair => l10n.parsingStatusWaitingPair,
|
||||||
|
RawMessageStatus.paired => l10n.parsingStatusPaired,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,24 @@ class ParsingSettingsScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_Card(
|
||||||
|
children: [
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
|
secondary: Icon(Icons.swap_horiz, color: p.ink2),
|
||||||
|
title: Text(l10n.parsingTransferPairingLabel,
|
||||||
|
style: TextStyle(fontSize: 14, color: p.ink)),
|
||||||
|
subtitle: Text(l10n.parsingTransferPairingHint,
|
||||||
|
style: TextStyle(fontSize: 12, color: p.ink2)),
|
||||||
|
value: settings?.transferPairingEnabled ?? true,
|
||||||
|
activeThumbColor: p.accent,
|
||||||
|
onChanged: settings == null
|
||||||
|
? null
|
||||||
|
: (v) => controller.setTransferPairingEnabled(v),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
if (defaultTargetPlatform == TargetPlatform.android) ...[
|
if (defaultTargetPlatform == TargetPlatform.android) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_Card(
|
_Card(
|
||||||
|
|||||||
@@ -13,12 +13,15 @@ import '../../../transactions/presentation/widgets/account_picker_sheet.dart';
|
|||||||
import '../../../transactions/presentation/widgets/category_picker_sheet.dart';
|
import '../../../transactions/presentation/widgets/category_picker_sheet.dart';
|
||||||
import '../../../user/application/active_user_controller.dart';
|
import '../../../user/application/active_user_controller.dart';
|
||||||
import '../../application/rules_controller.dart';
|
import '../../application/rules_controller.dart';
|
||||||
|
import '../../application/source_apps_controller.dart';
|
||||||
import '../../domain/entities/parse_rule.dart';
|
import '../../domain/entities/parse_rule.dart';
|
||||||
|
import '../../domain/entities/source_app.dart';
|
||||||
import '../../domain/enums.dart';
|
import '../../domain/enums.dart';
|
||||||
|
|
||||||
/// Предзаполнение редактора при создании правила из Inbox.
|
/// Предзаполнение редактора при создании правила из Inbox.
|
||||||
class RuleEditorPrefill {
|
class RuleEditorPrefill {
|
||||||
const RuleEditorPrefill({
|
const RuleEditorPrefill({
|
||||||
|
required this.packageName,
|
||||||
required this.pattern,
|
required this.pattern,
|
||||||
required this.merchantCanonical,
|
required this.merchantCanonical,
|
||||||
this.categoryId,
|
this.categoryId,
|
||||||
@@ -26,6 +29,7 @@ class RuleEditorPrefill {
|
|||||||
this.matchMode = MatchMode.contains,
|
this.matchMode = MatchMode.contains,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final String packageName;
|
||||||
final String pattern;
|
final String pattern;
|
||||||
final String merchantCanonical;
|
final String merchantCanonical;
|
||||||
final String? categoryId;
|
final String? categoryId;
|
||||||
@@ -36,6 +40,7 @@ class RuleEditorPrefill {
|
|||||||
/// Результат редактора в режиме compose (возвращается через pop в Inbox).
|
/// Результат редактора в режиме compose (возвращается через pop в Inbox).
|
||||||
class RuleEditorResult {
|
class RuleEditorResult {
|
||||||
const RuleEditorResult({
|
const RuleEditorResult({
|
||||||
|
required this.packageName,
|
||||||
required this.kind,
|
required this.kind,
|
||||||
required this.matchMode,
|
required this.matchMode,
|
||||||
required this.pattern,
|
required this.pattern,
|
||||||
@@ -45,6 +50,7 @@ class RuleEditorResult {
|
|||||||
this.priority = 0,
|
this.priority = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final String packageName;
|
||||||
final ParseRuleKind kind;
|
final ParseRuleKind kind;
|
||||||
final MatchMode matchMode;
|
final MatchMode matchMode;
|
||||||
final String pattern;
|
final String pattern;
|
||||||
@@ -72,6 +78,7 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
|||||||
final _merchantCtrl = TextEditingController();
|
final _merchantCtrl = TextEditingController();
|
||||||
ParseRuleKind _kind = ParseRuleKind.merchantToCategory;
|
ParseRuleKind _kind = ParseRuleKind.merchantToCategory;
|
||||||
MatchMode _matchMode = MatchMode.contains;
|
MatchMode _matchMode = MatchMode.contains;
|
||||||
|
String? _packageName;
|
||||||
String? _categoryId;
|
String? _categoryId;
|
||||||
String? _accountId;
|
String? _accountId;
|
||||||
int _priority = 0;
|
int _priority = 0;
|
||||||
@@ -81,11 +88,16 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
|||||||
|
|
||||||
bool get _isEdit => widget.ruleId != null;
|
bool get _isEdit => widget.ruleId != null;
|
||||||
|
|
||||||
|
/// Приложение выбирается только при создании «с нуля» из списка правил;
|
||||||
|
/// из Inbox (prefill) и при правке оно зафиксировано (read-only).
|
||||||
|
bool get _appIsPickable => !_isEdit && widget.prefill == null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final pf = widget.prefill;
|
final pf = widget.prefill;
|
||||||
if (pf != null) {
|
if (pf != null) {
|
||||||
|
_packageName = pf.packageName;
|
||||||
_patternCtrl.text = pf.pattern;
|
_patternCtrl.text = pf.pattern;
|
||||||
_merchantCtrl.text = pf.merchantCanonical;
|
_merchantCtrl.text = pf.merchantCanonical;
|
||||||
_matchMode = pf.matchMode;
|
_matchMode = pf.matchMode;
|
||||||
@@ -104,6 +116,7 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
|||||||
|
|
||||||
void _hydrateFromRule(ParseRule rule) {
|
void _hydrateFromRule(ParseRule rule) {
|
||||||
if (_initialized) return;
|
if (_initialized) return;
|
||||||
|
_packageName = rule.packageName;
|
||||||
_kind = rule.kind;
|
_kind = rule.kind;
|
||||||
_patternCtrl.text = rule.pattern;
|
_patternCtrl.text = rule.pattern;
|
||||||
_merchantCtrl.text = rule.merchantCanonical ?? '';
|
_merchantCtrl.text = rule.merchantCanonical ?? '';
|
||||||
@@ -116,11 +129,13 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool get _isSender => _kind == ParseRuleKind.senderToAccount;
|
bool get _isSender => _kind == ParseRuleKind.senderToAccount;
|
||||||
bool get _isMixed => _kind == ParseRuleKind.mixedMerchant;
|
|
||||||
|
|
||||||
/// Готовность к сохранению: для sender→account нужен счёт.
|
/// Готовность к сохранению: нужны приложение и (для sender→account) счёт.
|
||||||
|
/// При правке легаси-правила без packageName сохранение не блокируем.
|
||||||
bool get _canSave =>
|
bool get _canSave =>
|
||||||
_patternCtrl.text.trim().isNotEmpty && (!_isSender || _accountId != null);
|
_patternCtrl.text.trim().isNotEmpty &&
|
||||||
|
(_isEdit || _packageName != null) &&
|
||||||
|
(!_isSender || _accountId != null);
|
||||||
|
|
||||||
Future<void> _save(String userId, ParseRule? existing) async {
|
Future<void> _save(String userId, ParseRule? existing) async {
|
||||||
final pattern = _patternCtrl.text.trim();
|
final pattern = _patternCtrl.text.trim();
|
||||||
@@ -146,6 +161,7 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
|||||||
} else {
|
} else {
|
||||||
context.pop(
|
context.pop(
|
||||||
RuleEditorResult(
|
RuleEditorResult(
|
||||||
|
packageName: _packageName!,
|
||||||
kind: _kind,
|
kind: _kind,
|
||||||
matchMode: _matchMode,
|
matchMode: _matchMode,
|
||||||
pattern: pattern,
|
pattern: pattern,
|
||||||
@@ -184,6 +200,15 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
|||||||
final accountName = _accountId == null
|
final accountName = _accountId == null
|
||||||
? null
|
? null
|
||||||
: accounts.where((a) => a.id == _accountId).firstOrNull?.name;
|
: accounts.where((a) => a.id == _accountId).firstOrNull?.name;
|
||||||
|
final sourceApps = ref.watch(sourceAppsListProvider(userId)).value ??
|
||||||
|
const <SourceApp>[];
|
||||||
|
final appLabel = _packageName == null
|
||||||
|
? l10n.rulesUnknownApp
|
||||||
|
: sourceApps
|
||||||
|
.where((a) => a.packageName == _packageName)
|
||||||
|
.firstOrNull
|
||||||
|
?.displayName ??
|
||||||
|
_packageName!;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: p.paper,
|
backgroundColor: p.paper,
|
||||||
@@ -209,13 +234,59 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
|||||||
// Выбор вида — только при создании из списка правил. Из Inbox
|
// Выбор вида — только при создании из списка правил. Из Inbox
|
||||||
// (prefill) композим всегда merchant→category: senderToAccount там
|
// (prefill) композим всегда merchant→category: senderToAccount там
|
||||||
// не применяется (inbox_controller создаёт правило этого вида).
|
// не применяется (inbox_controller создаёт правило этого вида).
|
||||||
if (!_isEdit && widget.prefill == null) ...[
|
if (_appIsPickable) ...[
|
||||||
_KindSelector(
|
_KindSelector(
|
||||||
kind: _kind,
|
kind: _kind,
|
||||||
onChanged: (k) => setState(() => _kind = k),
|
onChanged: (k) => setState(() => _kind = k),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
|
// Приложение-источник: правило действует только внутри него.
|
||||||
|
// Выбирается при создании «с нуля»; из Inbox и при правке — read-only.
|
||||||
|
if (_appIsPickable)
|
||||||
|
if (sourceApps.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Text(l10n.ruleEditorNoApps,
|
||||||
|
style: TextStyle(fontSize: 13, color: p.negative)),
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
|
_FieldRow(
|
||||||
|
label: l10n.ruleEditorApp,
|
||||||
|
child: DropdownButton<String>(
|
||||||
|
value: _packageName,
|
||||||
|
isExpanded: true,
|
||||||
|
hint: Text(l10n.ruleEditorAppPick,
|
||||||
|
textAlign: TextAlign.end,
|
||||||
|
style: TextStyle(fontSize: 14, color: p.ink2)),
|
||||||
|
underline: const SizedBox.shrink(),
|
||||||
|
alignment: AlignmentDirectional.centerEnd,
|
||||||
|
items: [
|
||||||
|
for (final app in sourceApps)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: app.packageName,
|
||||||
|
child: Text(app.displayName ?? app.packageName,
|
||||||
|
style: TextStyle(fontSize: 14, color: p.ink)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (v) => setState(() => _packageName = v),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_Divider(color: p.line),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
]
|
||||||
|
else ...[
|
||||||
|
_FieldRow(
|
||||||
|
label: l10n.ruleEditorApp,
|
||||||
|
child: Text(
|
||||||
|
appLabel,
|
||||||
|
textAlign: TextAlign.end,
|
||||||
|
style: TextStyle(fontSize: 14, color: p.ink),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_Divider(color: p.line),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
Text(l10n.ruleEditorIfContains,
|
Text(l10n.ruleEditorIfContains,
|
||||||
style: TextStyle(fontSize: 13, color: p.ink2)),
|
style: TextStyle(fontSize: 13, color: p.ink2)),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
@@ -238,58 +309,53 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
|||||||
onChanged: (m) => setState(() => _matchMode = m),
|
onChanged: (m) => setState(() => _matchMode = m),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
if (_isMixed)
|
Text(l10n.ruleEditorThen,
|
||||||
Text(l10n.ruleKindMixedHint,
|
style: TextStyle(fontSize: 13, color: p.ink2)),
|
||||||
style: TextStyle(fontSize: 13, color: p.ink2, height: 1.3)),
|
const SizedBox(height: 8),
|
||||||
if (!_isMixed) ...[
|
if (!_isSender) ...[
|
||||||
Text(l10n.ruleEditorThen,
|
_FieldRow(
|
||||||
style: TextStyle(fontSize: 13, color: p.ink2)),
|
label: l10n.ruleEditorMerchant,
|
||||||
const SizedBox(height: 8),
|
child: TextField(
|
||||||
if (!_isSender) ...[
|
controller: _merchantCtrl,
|
||||||
_FieldRow(
|
textAlign: TextAlign.end,
|
||||||
label: l10n.ruleEditorMerchant,
|
decoration: InputDecoration(
|
||||||
child: TextField(
|
hintText: l10n.ruleEditorMerchantHint,
|
||||||
controller: _merchantCtrl,
|
isDense: true,
|
||||||
textAlign: TextAlign.end,
|
border: InputBorder.none,
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: l10n.ruleEditorMerchantHint,
|
|
||||||
isDense: true,
|
|
||||||
border: InputBorder.none,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_Divider(color: p.line),
|
),
|
||||||
_PickerRow(
|
_Divider(color: p.line),
|
||||||
label: l10n.ruleEditorCategory,
|
|
||||||
value: categoryName ?? l10n.inboxNoCategory,
|
|
||||||
onTap: () async {
|
|
||||||
final id = await showCategoryPicker(
|
|
||||||
context,
|
|
||||||
userId: userId,
|
|
||||||
type: CategoryType.expense,
|
|
||||||
currentCategoryId: _categoryId,
|
|
||||||
);
|
|
||||||
if (id != null) setState(() => _categoryId = id);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
_Divider(color: p.line),
|
|
||||||
],
|
|
||||||
_PickerRow(
|
_PickerRow(
|
||||||
label: l10n.ruleEditorAccount,
|
label: l10n.ruleEditorCategory,
|
||||||
value: accountName ??
|
value: categoryName ?? l10n.inboxNoCategory,
|
||||||
(_isSender
|
|
||||||
? l10n.ruleEditorAccountPick
|
|
||||||
: l10n.ruleEditorAccountUnchanged),
|
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final id = await showAccountPicker(
|
final id = await showCategoryPicker(
|
||||||
context,
|
context,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
currentAccountId: _accountId,
|
type: CategoryType.expense,
|
||||||
|
currentCategoryId: _categoryId,
|
||||||
);
|
);
|
||||||
if (id != null) setState(() => _accountId = id);
|
if (id != null) setState(() => _categoryId = id);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
_Divider(color: p.line),
|
||||||
],
|
],
|
||||||
|
_PickerRow(
|
||||||
|
label: l10n.ruleEditorAccount,
|
||||||
|
value: accountName ??
|
||||||
|
(_isSender
|
||||||
|
? l10n.ruleEditorAccountPick
|
||||||
|
: l10n.ruleEditorAccountUnchanged),
|
||||||
|
onTap: () async {
|
||||||
|
final id = await showAccountPicker(
|
||||||
|
context,
|
||||||
|
userId: userId,
|
||||||
|
currentAccountId: _accountId,
|
||||||
|
);
|
||||||
|
if (id != null) setState(() => _accountId = id);
|
||||||
|
},
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_AdvancedToggle(
|
_AdvancedToggle(
|
||||||
expanded: _advanced,
|
expanded: _advanced,
|
||||||
@@ -326,6 +392,7 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
|
|||||||
userId: userId,
|
userId: userId,
|
||||||
pattern: _patternCtrl.text,
|
pattern: _patternCtrl.text,
|
||||||
mode: _matchMode,
|
mode: _matchMode,
|
||||||
|
packageName: _packageName,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
@@ -347,20 +414,23 @@ class _MatchesPreview extends ConsumerWidget {
|
|||||||
required this.userId,
|
required this.userId,
|
||||||
required this.pattern,
|
required this.pattern,
|
||||||
required this.mode,
|
required this.mode,
|
||||||
|
required this.packageName,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String userId;
|
final String userId;
|
||||||
final String pattern;
|
final String pattern;
|
||||||
final MatchMode mode;
|
final MatchMode mode;
|
||||||
|
final String? packageName;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final p = context.palette;
|
final p = context.palette;
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
if (pattern.trim().isEmpty) return const SizedBox.shrink();
|
if (pattern.trim().isEmpty) return const SizedBox.shrink();
|
||||||
final matches =
|
final matches = ref
|
||||||
ref.watch(ruleMatchPreviewProvider(userId, pattern, mode)).value ??
|
.watch(ruleMatchPreviewProvider(userId, pattern, mode, packageName))
|
||||||
const [];
|
.value ??
|
||||||
|
const [];
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import '../../../categories/application/categories_controller.dart';
|
|||||||
import '../../../categories/domain/entities/category.dart';
|
import '../../../categories/domain/entities/category.dart';
|
||||||
import '../../../user/application/active_user_controller.dart';
|
import '../../../user/application/active_user_controller.dart';
|
||||||
import '../../application/rules_controller.dart';
|
import '../../application/rules_controller.dart';
|
||||||
|
import '../../application/source_apps_controller.dart';
|
||||||
import '../../domain/entities/parse_rule.dart';
|
import '../../domain/entities/parse_rule.dart';
|
||||||
|
import '../../domain/entities/source_app.dart';
|
||||||
import '../../domain/enums.dart';
|
import '../../domain/enums.dart';
|
||||||
import '../widgets/rule_card.dart';
|
import '../widgets/rule_card.dart';
|
||||||
import 'rule_editor_screen.dart';
|
import 'rule_editor_screen.dart';
|
||||||
@@ -56,6 +58,22 @@ class _RulesListScreenState extends ConsumerState<RulesListScreen> {
|
|||||||
final accounts =
|
final accounts =
|
||||||
ref.watch(accountsStreamProvider(userId)).value ?? const <Account>[];
|
ref.watch(accountsStreamProvider(userId)).value ?? const <Account>[];
|
||||||
final accountById = {for (final a in accounts) a.id: a};
|
final accountById = {for (final a in accounts) a.id: a};
|
||||||
|
final sourceApps = ref.watch(sourceAppsListProvider(userId)).value ??
|
||||||
|
const <SourceApp>[];
|
||||||
|
final displayNameByPackage = {
|
||||||
|
for (final a in sourceApps) a.packageName: a.displayName,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Секции по приложению-источнику: заголовок = displayName ?? packageName
|
||||||
|
// (легаси-правила без привязки — в секцию «Неизвестное приложение»).
|
||||||
|
final groups = <String, List<ParseRule>>{};
|
||||||
|
for (final r in rules) {
|
||||||
|
final header = r.packageName == null
|
||||||
|
? l10n.rulesUnknownApp
|
||||||
|
: displayNameByPackage[r.packageName] ?? r.packageName!;
|
||||||
|
groups.putIfAbsent(header, () => []).add(r);
|
||||||
|
}
|
||||||
|
final sortedHeaders = groups.keys.toList()..sort();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: p.paper,
|
backgroundColor: p.paper,
|
||||||
@@ -105,20 +123,36 @@ class _RulesListScreenState extends ConsumerState<RulesListScreen> {
|
|||||||
style: TextStyle(fontSize: 14, color: p.ink2)),
|
style: TextStyle(fontSize: 14, color: p.ink2)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: ListView.separated(
|
: ListView(
|
||||||
itemCount: rules.length,
|
children: [
|
||||||
separatorBuilder: (_, _) =>
|
for (final header in sortedHeaders) ...[
|
||||||
Container(height: 1, color: p.line),
|
Padding(
|
||||||
itemBuilder: (context, i) => RuleCard(
|
padding:
|
||||||
rule: rules[i],
|
const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||||
categoryById: categoryById,
|
child: Text(
|
||||||
accountById: accountById,
|
header,
|
||||||
onTap: () =>
|
style: TextStyle(
|
||||||
context.push(AppRoutes.parsingRuleEdit(rules[i].id)),
|
fontSize: 13,
|
||||||
onToggle: (enabled) => ref
|
fontWeight: FontWeight.w600,
|
||||||
.read(rulesControllerProvider.notifier)
|
color: p.ink2,
|
||||||
.setEnabled(rules[i].id, enabled: enabled),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
for (final (i, rule) in groups[header]!.indexed) ...[
|
||||||
|
if (i > 0) Container(height: 1, color: p.line),
|
||||||
|
RuleCard(
|
||||||
|
rule: rule,
|
||||||
|
categoryById: categoryById,
|
||||||
|
accountById: accountById,
|
||||||
|
onTap: () => context
|
||||||
|
.push(AppRoutes.parsingRuleEdit(rule.id)),
|
||||||
|
onToggle: (enabled) => ref
|
||||||
|
.read(rulesControllerProvider.notifier)
|
||||||
|
.setEnabled(rule.id, enabled: enabled),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -132,6 +166,7 @@ class _RulesListScreenState extends ConsumerState<RulesListScreen> {
|
|||||||
if (result == null) return;
|
if (result == null) return;
|
||||||
await ref.read(rulesControllerProvider.notifier).create(
|
await ref.read(rulesControllerProvider.notifier).create(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
|
packageName: result.packageName,
|
||||||
kind: result.kind,
|
kind: result.kind,
|
||||||
matchMode: result.matchMode,
|
matchMode: result.matchMode,
|
||||||
pattern: result.pattern,
|
pattern: result.pattern,
|
||||||
|
|||||||
@@ -338,33 +338,66 @@ class _AddedAppTile extends ConsumerWidget {
|
|||||||
onTap: () => context.push(AppRoutes.parsingAppBindings(app.packageName)),
|
onTap: () => context.push(AppRoutes.parsingAppBindings(app.packageName)),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(14, 8, 8, 8),
|
padding: const EdgeInsets.fromLTRB(14, 8, 8, 8),
|
||||||
child: Row(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.apps_outlined, size: 20, color: p.ink2),
|
Row(
|
||||||
const SizedBox(width: 12),
|
children: [
|
||||||
Expanded(
|
Icon(Icons.apps_outlined, size: 20, color: p.ink2),
|
||||||
child: Column(
|
const SizedBox(width: 12),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Expanded(
|
||||||
children: [
|
child: Column(
|
||||||
Text(title,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: TextStyle(fontSize: 14, color: p.ink)),
|
children: [
|
||||||
if (app.displayName != null)
|
Text(title,
|
||||||
Text(app.packageName,
|
style: TextStyle(fontSize: 14, color: p.ink)),
|
||||||
style: TextStyle(fontSize: 11, color: p.ink2)),
|
if (app.displayName != null)
|
||||||
],
|
Text(app.packageName,
|
||||||
|
style: TextStyle(fontSize: 11, color: p.ink2)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
value: app.enabled,
|
||||||
|
activeThumbColor: p.accent,
|
||||||
|
onChanged: (v) => ctrl.setEnabled(app.id, enabled: v),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.delete_outline, size: 20, color: p.ink2),
|
||||||
|
tooltip: l10n.commonDelete,
|
||||||
|
onPressed: () => ctrl.delete(app.id),
|
||||||
|
),
|
||||||
|
Icon(Icons.chevron_right, size: 18, color: p.ink2),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// «Мерчант — само приложение» (Ozon): уведомления не называют
|
||||||
|
// продавца — категория выбирается вручную, правила не предлагаются.
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 32),
|
||||||
|
child: Tooltip(
|
||||||
|
message: l10n.sourceAppsSelfMerchantHint,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.storefront_outlined, size: 16, color: p.ink2),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(l10n.sourceAppsSelfMerchantLabel,
|
||||||
|
style: TextStyle(fontSize: 12, color: p.ink2)),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: 32,
|
||||||
|
width: 40,
|
||||||
|
child: Checkbox(
|
||||||
|
value: app.selfMerchant,
|
||||||
|
activeColor: p.accent,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
onChanged: (v) =>
|
||||||
|
ctrl.setSelfMerchant(app.id, value: v ?? false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Switch(
|
|
||||||
value: app.enabled,
|
|
||||||
activeThumbColor: p.accent,
|
|
||||||
onChanged: (v) => ctrl.setEnabled(app.id, enabled: v),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.delete_outline, size: 20, color: p.ink2),
|
|
||||||
tooltip: l10n.commonDelete,
|
|
||||||
onPressed: () => ctrl.delete(app.id),
|
|
||||||
),
|
|
||||||
Icon(Icons.chevron_right, size: 18, color: p.ink2),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import '../../../../app/l10n/l10n.dart';
|
|||||||
import '../../../../app/router/app_routes.dart';
|
import '../../../../app/router/app_routes.dart';
|
||||||
import '../../../../app/theme/app_colors.dart';
|
import '../../../../app/theme/app_colors.dart';
|
||||||
import '../../../../core/database/converters/enum_converters.dart';
|
import '../../../../core/database/converters/enum_converters.dart';
|
||||||
|
import '../../../accounts/application/accounts_controller.dart';
|
||||||
import '../../../categories/domain/entities/category.dart';
|
import '../../../categories/domain/entities/category.dart';
|
||||||
import '../../../home/presentation/widgets/money_text.dart';
|
import '../../../home/presentation/widgets/money_text.dart';
|
||||||
|
import '../../../transactions/presentation/screens/transaction_form_screen.dart';
|
||||||
|
import '../../../transactions/presentation/widgets/account_picker_sheet.dart';
|
||||||
import '../../../transactions/presentation/widgets/category_picker_sheet.dart';
|
import '../../../transactions/presentation/widgets/category_picker_sheet.dart';
|
||||||
import '../../application/inbox_controller.dart';
|
import '../../application/inbox_controller.dart';
|
||||||
import '../../data/parser/draft_codec.dart';
|
import '../../data/parser/draft_codec.dart';
|
||||||
@@ -18,7 +21,7 @@ import 'confidence_badge.dart';
|
|||||||
import 'gate_check_labels.dart';
|
import 'gate_check_labels.dart';
|
||||||
|
|
||||||
/// Карточка Inbox (§12.2): мерчант, сумма, подсветка слабых полей и три
|
/// Карточка Inbox (§12.2): мерчант, сумма, подсветка слабых полей и три
|
||||||
/// действия — «Создать правило», «Подтвердить разово», «Игнорировать».
|
/// действия — «Создать правило», «Подтвердить», «Игнорировать».
|
||||||
class InboxCard extends ConsumerWidget {
|
class InboxCard extends ConsumerWidget {
|
||||||
const InboxCard({
|
const InboxCard({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -50,10 +53,17 @@ class InboxCard extends ConsumerWidget {
|
|||||||
? _FailedBody(message: message)
|
? _FailedBody(message: message)
|
||||||
: bundle == null
|
: bundle == null
|
||||||
? _UnrecognizedBody(message: message)
|
? _UnrecognizedBody(message: message)
|
||||||
|
// pairedRawMessageId → склеенная пара «Перевод между счетами».
|
||||||
|
: bundle.pairedRawMessageId != null
|
||||||
|
? _TransferPairBody(
|
||||||
|
message: message,
|
||||||
|
userId: userId,
|
||||||
|
bundle: bundle,
|
||||||
|
)
|
||||||
// suggestion != null → знакомый мерчант: предлагаем правило.
|
// suggestion != null → знакомый мерчант: предлагаем правило.
|
||||||
// suggestion == null → нет мерчанта (только сумма) ИЛИ мерчант
|
// suggestion == null → нет мерчанта (только сумма) ИЛИ источник
|
||||||
// помечен как mixed → «подтвердить разово» без создания правила.
|
// с selfMerchant → «Подтвердить» без создания правила.
|
||||||
: bundle.suggestion != null
|
: bundle.suggestion != null
|
||||||
? _RecognizedBody(
|
? _RecognizedBody(
|
||||||
message: message,
|
message: message,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
@@ -169,10 +179,11 @@ class _RecognizedBody extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_CreateRuleButton(
|
_SplitActionButton(
|
||||||
label: categoryName != null
|
label: categoryName != null
|
||||||
? l10n.inboxCreateRule(merchant, categoryName)
|
? l10n.inboxCreateRule(merchant, categoryName)
|
||||||
: l10n.inboxCreateRuleNoCategory(merchant),
|
: l10n.inboxCreateRuleNoCategory(merchant),
|
||||||
|
editTooltip: l10n.inboxEditRuleTooltip,
|
||||||
onTap: () => _createRule(
|
onTap: () => _createRule(
|
||||||
context,
|
context,
|
||||||
ref,
|
ref,
|
||||||
@@ -194,16 +205,24 @@ class _RecognizedBody extends ConsumerWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: _SecondaryButton(
|
child: _SecondaryButton(
|
||||||
icon: Icons.check,
|
icon: Icons.check,
|
||||||
label: l10n.inboxConfirmOnce,
|
label: l10n.inboxConfirm,
|
||||||
onTap: accountId == null
|
onTap: () async {
|
||||||
? null
|
final acc = await _resolveAccount(context,
|
||||||
: () => ref.read(inboxControllerProvider.notifier).confirmOnce(
|
userId: userId, accountId: accountId);
|
||||||
|
if (acc == null || !context.mounted) return;
|
||||||
|
await _runReporting(
|
||||||
|
context,
|
||||||
|
() => ref
|
||||||
|
.read(inboxControllerProvider.notifier)
|
||||||
|
.confirmOnce(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
message: message,
|
message: message,
|
||||||
draft: draft,
|
draft: draft,
|
||||||
accountId: accountId,
|
accountId: acc,
|
||||||
categoryId: categoryId,
|
categoryId: categoryId,
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -217,29 +236,6 @@ class _RecognizedBody extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// «Категории различаются» — мерчант вроде Ozon, у которого категория
|
|
||||||
// меняется от покупки к покупке: правило для него бессмысленно.
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: TextButton.icon(
|
|
||||||
onPressed: () =>
|
|
||||||
ref.read(inboxControllerProvider.notifier).markMerchantMixed(
|
|
||||||
userId: userId,
|
|
||||||
message: message,
|
|
||||||
bundle: bundle,
|
|
||||||
merchantCanonical: merchant,
|
|
||||||
),
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
foregroundColor: p.ink2,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
minimumSize: Size.zero,
|
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.shuffle, size: 14),
|
|
||||||
label: Text(l10n.inboxMarkMixed,
|
|
||||||
style: const TextStyle(fontSize: 12)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -251,22 +247,27 @@ class _RecognizedBody extends ConsumerWidget {
|
|||||||
required String? categoryId,
|
required String? categoryId,
|
||||||
required String merchant,
|
required String merchant,
|
||||||
}) async {
|
}) async {
|
||||||
if (accountId == null) return;
|
|
||||||
final draft = bundle.draft;
|
final draft = bundle.draft;
|
||||||
if (categoryId == null) {
|
if (categoryId == null) {
|
||||||
await _openEditor(context, ref,
|
await _openEditor(context, ref,
|
||||||
accountId: accountId, categoryId: categoryId, merchant: merchant);
|
accountId: accountId, categoryId: categoryId, merchant: merchant);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await ref.read(inboxControllerProvider.notifier).createRule(
|
final acc =
|
||||||
userId: userId,
|
await _resolveAccount(context, userId: userId, accountId: accountId);
|
||||||
message: message,
|
if (acc == null || !context.mounted) return;
|
||||||
draft: draft,
|
await _runReporting(
|
||||||
accountId: accountId,
|
context,
|
||||||
categoryId: categoryId,
|
() => ref.read(inboxControllerProvider.notifier).createRule(
|
||||||
merchantCanonical: merchant,
|
userId: userId,
|
||||||
pattern: draft.merchantRaw ?? merchant,
|
message: message,
|
||||||
);
|
draft: draft,
|
||||||
|
accountId: acc,
|
||||||
|
categoryId: categoryId,
|
||||||
|
merchantCanonical: merchant,
|
||||||
|
pattern: draft.merchantRaw ?? merchant,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openEditor(
|
Future<void> _openEditor(
|
||||||
@@ -280,33 +281,65 @@ class _RecognizedBody extends ConsumerWidget {
|
|||||||
final result = await context.push<RuleEditorResult?>(
|
final result = await context.push<RuleEditorResult?>(
|
||||||
AppRoutes.parsingRuleNew,
|
AppRoutes.parsingRuleNew,
|
||||||
extra: RuleEditorPrefill(
|
extra: RuleEditorPrefill(
|
||||||
|
packageName: message.packageName,
|
||||||
pattern: draft.merchantRaw ?? merchant,
|
pattern: draft.merchantRaw ?? merchant,
|
||||||
merchantCanonical: merchant,
|
merchantCanonical: merchant,
|
||||||
categoryId: categoryId,
|
categoryId: categoryId,
|
||||||
accountId: accountId,
|
accountId: accountId,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (result == null) return;
|
if (result == null || !context.mounted) return;
|
||||||
final acc = result.accountId ?? accountId;
|
final acc = await _resolveAccount(context,
|
||||||
if (acc == null) return;
|
userId: userId, accountId: result.accountId ?? accountId);
|
||||||
await ref.read(inboxControllerProvider.notifier).createRule(
|
if (acc == null || !context.mounted) return;
|
||||||
userId: userId,
|
await _runReporting(
|
||||||
message: message,
|
context,
|
||||||
draft: draft,
|
() => ref.read(inboxControllerProvider.notifier).createRule(
|
||||||
accountId: acc,
|
userId: userId,
|
||||||
categoryId: result.categoryId,
|
message: message,
|
||||||
merchantCanonical:
|
draft: draft,
|
||||||
result.merchantCanonical.isEmpty ? merchant : result.merchantCanonical,
|
accountId: acc,
|
||||||
pattern: result.pattern,
|
categoryId: result.categoryId,
|
||||||
matchMode: result.matchMode,
|
merchantCanonical: result.merchantCanonical.isEmpty
|
||||||
);
|
? merchant
|
||||||
|
: result.merchantCanonical,
|
||||||
|
pattern: result.pattern,
|
||||||
|
matchMode: result.matchMode,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Выполняет действие контроллера и показывает SnackBar при ошибке —
|
||||||
|
/// InboxController делает rethrow, и без обработки тап выглядит как no-op.
|
||||||
|
Future<void> _runReporting(
|
||||||
|
BuildContext context,
|
||||||
|
Future<void> Function() action,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} catch (e) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text(e.toString())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Счёт для подтверждения: переданный [accountId] (draft/дефолт), иначе —
|
||||||
|
/// пикер счёта. `null` из пикера = пользователь отменил.
|
||||||
|
Future<String?> _resolveAccount(
|
||||||
|
BuildContext context, {
|
||||||
|
required String userId,
|
||||||
|
String? accountId,
|
||||||
|
}) async {
|
||||||
|
if (accountId != null) return accountId;
|
||||||
|
return showAccountPicker(context, userId: userId);
|
||||||
|
}
|
||||||
|
|
||||||
/// Карточка без предложения правила (§9.1): уведомление без мерчанта (только
|
/// Карточка без предложения правила (§9.1): уведомление без мерчанта (только
|
||||||
/// сумма) или мерчант помечен как mixed. Создавать merchant→category-правило
|
/// сумма) или источник с selfMerchant (Ozon). Создавать merchant→category
|
||||||
/// тут нельзя/бессмысленно, поэтому единственное действие — выбрать категорию
|
/// правило тут нельзя/бессмысленно: пользователь выбирает категорию
|
||||||
/// и «подтвердить разово».
|
/// (обязательно) и подтверждает — либо уходит в полную форму карандашом.
|
||||||
class _ConfirmOnceBody extends ConsumerStatefulWidget {
|
class _ConfirmOnceBody extends ConsumerStatefulWidget {
|
||||||
const _ConfirmOnceBody({
|
const _ConfirmOnceBody({
|
||||||
required this.message,
|
required this.message,
|
||||||
@@ -358,10 +391,22 @@ class _ConfirmOnceBodyState extends ConsumerState<_ConfirmOnceBody> {
|
|||||||
draft.merchantRaw ??
|
draft.merchantRaw ??
|
||||||
message.title ??
|
message.title ??
|
||||||
'—';
|
'—';
|
||||||
final accountId = draft.accountId ?? widget.defaultAccountId;
|
|
||||||
final categoryName =
|
final categoryName =
|
||||||
_categoryId != null ? widget.categoryById[_categoryId]?.name : null;
|
_categoryId != null ? widget.categoryById[_categoryId]?.name : null;
|
||||||
|
|
||||||
|
// Чипы частых категорий этого приложения — быстрый выбор без пикера.
|
||||||
|
final topCategoryIds = ref
|
||||||
|
.watch(topConfirmCategoriesProvider(
|
||||||
|
widget.userId, message.packageName, draft.type))
|
||||||
|
.value ??
|
||||||
|
const <String>[];
|
||||||
|
final chipCategories = topCategoryIds
|
||||||
|
.map((id) => widget.categoryById[id])
|
||||||
|
.whereType<Category>()
|
||||||
|
.where((c) => !c.archived)
|
||||||
|
.take(4)
|
||||||
|
.toList();
|
||||||
|
|
||||||
final signed =
|
final signed =
|
||||||
draft.type == TransactionType.expense ? -draft.amount : draft.amount;
|
draft.type == TransactionType.expense ? -draft.amount : draft.amount;
|
||||||
final amountColor =
|
final amountColor =
|
||||||
@@ -410,7 +455,22 @@ class _ConfirmOnceBodyState extends ConsumerState<_ConfirmOnceBody> {
|
|||||||
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3)),
|
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3)),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
// Строка выбора категории (необязательно — можно подтвердить и без неё).
|
if (chipCategories.isNotEmpty) ...[
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: [
|
||||||
|
for (final c in chipCategories)
|
||||||
|
_CategoryChip(
|
||||||
|
label: c.name,
|
||||||
|
selected: _categoryId == c.id,
|
||||||
|
onTap: () => setState(() => _categoryId = c.id),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
// Строка выбора категории — категория обязательна для подтверждения.
|
||||||
InkWell(
|
InkWell(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
@@ -446,31 +506,21 @@ class _ConfirmOnceBodyState extends ConsumerState<_ConfirmOnceBody> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (_categoryId == null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(l10n.inboxCategoryRequiredHint,
|
||||||
|
style: TextStyle(fontSize: 12, color: p.ink2)),
|
||||||
|
],
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
child: FilledButton.icon(
|
child: _SplitActionButton(
|
||||||
onPressed: accountId == null
|
label: l10n.inboxConfirm,
|
||||||
? null
|
editTooltip: l10n.inboxEditRuleTooltip,
|
||||||
: () => ref
|
onTap: _categoryId == null ? null : _confirm,
|
||||||
.read(inboxControllerProvider.notifier)
|
onEdit: _openFullForm,
|
||||||
.confirmOnce(
|
|
||||||
userId: widget.userId,
|
|
||||||
message: message,
|
|
||||||
draft: draft,
|
|
||||||
accountId: accountId,
|
|
||||||
categoryId: _categoryId,
|
|
||||||
),
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: p.accent,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.check, size: 16),
|
|
||||||
label: Text(l10n.inboxConfirm,
|
|
||||||
overflow: TextOverflow.ellipsis),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -487,6 +537,323 @@ class _ConfirmOnceBodyState extends ConsumerState<_ConfirmOnceBody> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Галочка: мгновенная транзакция с выбранной категорией. Без счёта —
|
||||||
|
/// открывает пикер счёта (отмена пикера = ничего не делаем).
|
||||||
|
Future<void> _confirm() async {
|
||||||
|
final message = widget.message;
|
||||||
|
final draft = widget.bundle.draft;
|
||||||
|
final acc = await _resolveAccount(
|
||||||
|
context,
|
||||||
|
userId: widget.userId,
|
||||||
|
accountId: draft.accountId ?? widget.defaultAccountId,
|
||||||
|
);
|
||||||
|
if (acc == null || !mounted) return;
|
||||||
|
await _runReporting(
|
||||||
|
context,
|
||||||
|
() => ref.read(inboxControllerProvider.notifier).confirmOnce(
|
||||||
|
userId: widget.userId,
|
||||||
|
message: message,
|
||||||
|
draft: draft,
|
||||||
|
accountId: acc,
|
||||||
|
categoryId: _categoryId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Карандаш: полная форма транзакции с префиллом — там можно вписать
|
||||||
|
/// реального мерчанта. После сохранения сообщение линкуется (→ applied).
|
||||||
|
Future<void> _openFullForm() async {
|
||||||
|
final message = widget.message;
|
||||||
|
final draft = widget.bundle.draft;
|
||||||
|
final txId = await context.push<String?>(
|
||||||
|
AppRoutes.transactionNew,
|
||||||
|
extra: TransactionFormPrefill(
|
||||||
|
type: draft.type,
|
||||||
|
amountMinor: draft.amount,
|
||||||
|
date: draft.dateTime ?? message.receivedAt,
|
||||||
|
accountId: draft.accountId ?? widget.defaultAccountId,
|
||||||
|
categoryId: _categoryId,
|
||||||
|
merchant: draft.merchantCanonical ?? draft.merchantRaw ?? message.title,
|
||||||
|
rawMessageId: message.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (txId == null || txId.isEmpty) return;
|
||||||
|
await ref
|
||||||
|
.read(inboxControllerProvider.notifier)
|
||||||
|
.markApplied(message, txId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Склеенная пара «Перевод между счетами»: сумма, счёт-источник → счёт
|
||||||
|
/// зачисления (пикеры при неразрешённых счетах), оба приложения-источника.
|
||||||
|
/// Действия: «Подтвердить» (одна transfer-транзакция), «Расклеить»
|
||||||
|
/// (две одиночные карточки + blocklist), «Игнорировать» (обе половинки).
|
||||||
|
class _TransferPairBody extends ConsumerStatefulWidget {
|
||||||
|
const _TransferPairBody({
|
||||||
|
required this.message,
|
||||||
|
required this.userId,
|
||||||
|
required this.bundle,
|
||||||
|
});
|
||||||
|
|
||||||
|
final RawMessage message;
|
||||||
|
final String userId;
|
||||||
|
final DraftBundle bundle;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_TransferPairBody> createState() => _TransferPairBodyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TransferPairBodyState extends ConsumerState<_TransferPairBody> {
|
||||||
|
String? _fromId;
|
||||||
|
String? _toId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fromId = widget.bundle.draft.accountId;
|
||||||
|
_toId = widget.bundle.draft.transferToAccountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final p = context.palette;
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final draft = widget.bundle.draft;
|
||||||
|
final secondaryId = widget.bundle.pairedRawMessageId!;
|
||||||
|
|
||||||
|
final accounts =
|
||||||
|
ref.watch(accountsStreamProvider(widget.userId)).value ?? const [];
|
||||||
|
final accountName = {for (final a in accounts) a.id: a.name};
|
||||||
|
|
||||||
|
final primaryApp = ref
|
||||||
|
.watch(sourceAppLabelProvider(widget.userId, widget.message.packageName))
|
||||||
|
.value;
|
||||||
|
final secondary = ref.watch(pairedRawMessageProvider(secondaryId)).value;
|
||||||
|
final secondaryApp = secondary == null
|
||||||
|
? null
|
||||||
|
: ref
|
||||||
|
.watch(sourceAppLabelProvider(widget.userId, secondary.packageName))
|
||||||
|
.value;
|
||||||
|
final appsLine = [primaryApp, secondaryApp].nonNulls.join(' → ');
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.swap_horiz, size: 18, color: p.accent),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.inboxTransferPairTitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: p.ink,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MoneyText(draft.amount,
|
||||||
|
color: p.ink, fontSize: 15, fontWeight: FontWeight.w700),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (appsLine.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(appsLine,
|
||||||
|
style: TextStyle(fontSize: 13, color: p.ink2),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _AccountSlot(
|
||||||
|
label: l10n.inboxTransferFrom,
|
||||||
|
name: _fromId != null ? accountName[_fromId] : null,
|
||||||
|
onTap: () => _pickAccount(isFrom: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
|
child: Icon(Icons.arrow_forward, size: 16, color: p.ink2),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _AccountSlot(
|
||||||
|
label: l10n.inboxTransferTo,
|
||||||
|
name: _toId != null ? accountName[_toId] : null,
|
||||||
|
onTap: () => _pickAccount(isFrom: false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
widget.message.body,
|
||||||
|
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(backgroundColor: p.accent),
|
||||||
|
onPressed: _confirm,
|
||||||
|
child: Text(l10n.inboxConfirm),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _SecondaryButton(
|
||||||
|
icon: Icons.link_off,
|
||||||
|
label: l10n.inboxUnpair,
|
||||||
|
onTap: () => _runReporting(
|
||||||
|
context,
|
||||||
|
() => ref.read(inboxControllerProvider.notifier).unpair(
|
||||||
|
userId: widget.userId,
|
||||||
|
message: widget.message,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _SecondaryButton(
|
||||||
|
icon: Icons.close,
|
||||||
|
label: l10n.inboxIgnore,
|
||||||
|
onTap: () => ref
|
||||||
|
.read(inboxControllerProvider.notifier)
|
||||||
|
.ignore(widget.message),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickAccount({required bool isFrom}) async {
|
||||||
|
final id = await showAccountPicker(context, userId: widget.userId);
|
||||||
|
if (id == null || !mounted) return;
|
||||||
|
setState(() => isFrom ? _fromId = id : _toId = id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// «Подтвердить»: недостающие счета запрашиваются пикером по тапу
|
||||||
|
/// (кнопку не дизейблим), затем создаётся одна transfer-транзакция.
|
||||||
|
Future<void> _confirm() async {
|
||||||
|
var from = _fromId;
|
||||||
|
if (from == null) {
|
||||||
|
from = await showAccountPicker(context, userId: widget.userId);
|
||||||
|
if (from == null || !mounted) return;
|
||||||
|
setState(() => _fromId = from);
|
||||||
|
}
|
||||||
|
var to = _toId;
|
||||||
|
if (to == null) {
|
||||||
|
to = await showAccountPicker(context, userId: widget.userId);
|
||||||
|
if (to == null || !mounted) return;
|
||||||
|
setState(() => _toId = to);
|
||||||
|
}
|
||||||
|
if (from == to) return; // перевод на тот же счёт не имеет смысла
|
||||||
|
await _runReporting(
|
||||||
|
context,
|
||||||
|
() => ref.read(inboxControllerProvider.notifier).confirmPair(
|
||||||
|
userId: widget.userId,
|
||||||
|
message: widget.message,
|
||||||
|
draft: widget.bundle.draft,
|
||||||
|
secondaryId: widget.bundle.pairedRawMessageId!,
|
||||||
|
fromAccountId: from!,
|
||||||
|
toAccountId: to!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Слот счёта в merged-карточке: метка + имя счёта (или «выбрать»).
|
||||||
|
class _AccountSlot extends StatelessWidget {
|
||||||
|
const _AccountSlot({
|
||||||
|
required this.label,
|
||||||
|
required this.name,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String? name;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final p = context.palette;
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: p.paper,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: p.line),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: TextStyle(fontSize: 11, color: p.ink2)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
name ?? context.l10n.inboxTransferPickAccount,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: name != null ? p.ink : p.accent,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Пилюля быстрого выбора категории (частые категории приложения).
|
||||||
|
class _CategoryChip extends StatelessWidget {
|
||||||
|
const _CategoryChip({
|
||||||
|
required this.label,
|
||||||
|
required this.selected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final p = context.palette;
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? p.accentSoft : p.paper,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: selected ? p.accent : p.line),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: selected ? p.accent : p.ink,
|
||||||
|
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UnrecognizedBody extends ConsumerWidget {
|
class _UnrecognizedBody extends ConsumerWidget {
|
||||||
@@ -599,21 +966,26 @@ class _FailedBody extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CreateRuleButton extends StatelessWidget {
|
/// Акцентная сплит-кнопка «действие | ✎»: основной сегмент выполняет действие
|
||||||
const _CreateRuleButton({
|
/// ([onTap] == null — сегмент неактивен и приглушён), карандаш всегда активен
|
||||||
|
/// и открывает редактор/форму. Используется для «Создать правило» и
|
||||||
|
/// «Подтвердить».
|
||||||
|
class _SplitActionButton extends StatelessWidget {
|
||||||
|
const _SplitActionButton({
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
required this.onEdit,
|
required this.onEdit,
|
||||||
|
required this.editTooltip,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String label;
|
final String label;
|
||||||
final VoidCallback onTap;
|
final VoidCallback? onTap;
|
||||||
final VoidCallback onEdit;
|
final VoidCallback onEdit;
|
||||||
|
final String editTooltip;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final p = context.palette;
|
final p = context.palette;
|
||||||
final l10n = context.l10n;
|
|
||||||
return Material(
|
return Material(
|
||||||
color: p.accent,
|
color: p.accent,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -623,17 +995,20 @@ class _CreateRuleButton extends StatelessWidget {
|
|||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: const BorderRadius.horizontal(left: Radius.circular(12)),
|
borderRadius: const BorderRadius.horizontal(left: Radius.circular(12)),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Padding(
|
child: Opacity(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
opacity: onTap == null ? 0.55 : 1,
|
||||||
child: Text(
|
child: Padding(
|
||||||
label,
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
style: const TextStyle(
|
child: Text(
|
||||||
fontSize: 14,
|
label,
|
||||||
fontWeight: FontWeight.w600,
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -643,7 +1018,7 @@ class _CreateRuleButton extends StatelessWidget {
|
|||||||
borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)),
|
borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)),
|
||||||
onTap: onEdit,
|
onTap: onEdit,
|
||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
message: l10n.inboxEditRuleTooltip,
|
message: editTooltip,
|
||||||
child: const Padding(
|
child: const Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
padding: EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
child: Icon(Icons.edit_outlined, size: 18, color: Colors.white),
|
child: Icon(Icons.edit_outlined, size: 18, color: Colors.white),
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ class RuleCard extends StatelessWidget {
|
|||||||
ParseRuleKind.merchantToCategory => Icons.storefront_outlined,
|
ParseRuleKind.merchantToCategory => Icons.storefront_outlined,
|
||||||
ParseRuleKind.senderToAccount => Icons.credit_card_outlined,
|
ParseRuleKind.senderToAccount => Icons.credit_card_outlined,
|
||||||
ParseRuleKind.ignore => Icons.block_outlined,
|
ParseRuleKind.ignore => Icons.block_outlined,
|
||||||
ParseRuleKind.mixedMerchant => Icons.shuffle,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
final categoryName =
|
final categoryName =
|
||||||
|
|||||||